diff --git a/.gitignore b/.gitignore index e25a6b0c..658d7bc1 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules coverage *.log +dump.rdb docs/.vitepress/cache docs/.vitepress/dist diff --git a/CLAUDE.md b/CLAUDE.md index 119f328f..479031ab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,7 @@ Igo is a Node.js full-stack web framework built on Express, providing ORM, templ │ ├── server/ # @igojs/server - Express framework core │ └── component/ # @igojs/component - Reactive components with SSR ├── docs/ # VitePress documentation (deployed to GitHub Pages) +│ └── adr/ # Architecture decision records (internal, not published) ├── package.json # Root workspace configuration └── CHANGELOG.md # Version history ``` @@ -51,8 +52,12 @@ Igo is a Node.js full-stack web framework built on Express, providing ORM, templ - i18next internationalization - Redis caching, email (nodemailer + MJML) - CLI for scaffolding and database commands +- JSON APIs: schema validation, RFC 9457 errors, structured logging - **Entry:** `packages/server/src/index.js` - **CLI:** `packages/server/cli/igo.js` +- **JSON API layer:** `packages/server/src/api/` +- **TypeScript types:** `packages/server/index.d.ts` +- **Project skeletons:** `packages/server/skel/` — `fullstack` scaffolds a TypeScript API + React SPA monorepo with its own tooling (pnpm, oxlint, oxfmt); `tailwind` is the server-rendered igo app ### @igojs/component (Reactive Components) - Single-file `.dust` components (` + + diff --git a/packages/server/skel/fullstack/front/package.json b/packages/server/skel/fullstack/front/package.json new file mode 100644 index 00000000..8b540246 --- /dev/null +++ b/packages/server/skel/fullstack/front/package.json @@ -0,0 +1,47 @@ +{ + "name": "{project.name}-front", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "lint": "oxlint", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "format": "oxfmt", + "format:check": "oxfmt --check" + }, + "dependencies": { + "@grafana/faro-react": "^2.11.0", + "@grafana/faro-web-tracing": "^2.11.0", + "@tanstack/react-query": "^5.102.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-router": "^8.3.0" + }, + "devDependencies": { + "@grafana/faro-rollup-plugin": "^0.12.0", + "@tailwindcss/vite": "^4.3.0", + "@testing-library/jest-dom": "^6.9.0", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.0", + "@types/node": "^24.13.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^6.1.0", + "jsdom": "^30.0.0", + "msw": "^2.12.0", + "oxfmt": "^0.66.0", + "oxlint": "^1.81.0", + "tailwindcss": "^4.3.0", + "typescript": "^7.0.0", + "vite": "^8.2.0", + "vitest": "^5.0.0" + }, + "engines": { + "node": ">=24" + } +} diff --git a/packages/server/skel/fullstack/front/src/components/layout/app-layout.tsx b/packages/server/skel/fullstack/front/src/components/layout/app-layout.tsx new file mode 100644 index 00000000..c9164f15 --- /dev/null +++ b/packages/server/skel/fullstack/front/src/components/layout/app-layout.tsx @@ -0,0 +1,9 @@ +import { Outlet } from 'react-router'; + +export function AppLayout() { + return ( +
+ +
+ ); +} diff --git a/packages/server/skel/fullstack/front/src/components/layout/error-boundary.tsx b/packages/server/skel/fullstack/front/src/components/layout/error-boundary.tsx new file mode 100644 index 00000000..3c1e4f41 --- /dev/null +++ b/packages/server/skel/fullstack/front/src/components/layout/error-boundary.tsx @@ -0,0 +1,14 @@ +import { FaroErrorBoundary } from '@grafana/faro-react'; +import type { ReactNode } from 'react'; + +import { ErrorPage } from './error-page'; + +// La frontière d'erreur racine : un composant qui lève affiche une page +// générique au lieu d'un écran blanc, et l'incident est signalé. +// +// Le fournisseur d'observabilité est enfermé ici, comme il l'est dans +// reportError() : le point d'entrée de l'application n'a pas à le nommer, et le +// remplacer ne touche que ce fichier. +export function ErrorBoundary({ children }: { children: ReactNode }) { + return }>{children}; +} diff --git a/packages/server/skel/fullstack/front/src/components/layout/error-page.tsx b/packages/server/skel/fullstack/front/src/components/layout/error-page.tsx new file mode 100644 index 00000000..f5b33c9b --- /dev/null +++ b/packages/server/skel/fullstack/front/src/components/layout/error-page.tsx @@ -0,0 +1,20 @@ +// Affichée par la frontière d'erreur racine quand un composant lève. Une page +// générique vaut mieux qu'un écran blanc, qui est précisément ce qui rend une +// panne du front invisible pour tout le monde sauf l'utilisateur. +export function ErrorPage() { + return ( +
+

Une erreur est survenue

+

+ L'incident a été signalé. Vous pouvez recharger la page pour reprendre. +

+ +
+ ); +} diff --git a/packages/server/skel/fullstack/front/src/components/layout/route-error.tsx b/packages/server/skel/fullstack/front/src/components/layout/route-error.tsx new file mode 100644 index 00000000..548a599d --- /dev/null +++ b/packages/server/skel/fullstack/front/src/components/layout/route-error.tsx @@ -0,0 +1,27 @@ +import { useEffect } from 'react'; +import { isRouteErrorResponse, useRouteError } from 'react-router'; + +import { reportError } from '@/lib/report-error'; + +import { ErrorPage } from './error-page'; + +// react-router intercepte lui-même ce qui échoue dans une route — chargement +// d'un module lazy, loader, action — et n'atteint donc jamais la frontière +// d'erreur racine. Sans errorElement, il affiche sa page de secours, et +// l'incident ne remonte nulle part. +// +// Un 404 n'est pas un incident : c'est un lien mort ou une URL saisie à la +// main, que le taux d'erreur des métriques porte déjà. On ne remonte que le +// reste. +export function RouteError() { + const error = useRouteError(); + const expected = isRouteErrorResponse(error) && error.status === 404; + + useEffect(() => { + if (!expected) { + reportError(error, { origine: 'route' }); + } + }, [error, expected]); + + return ; +} diff --git a/packages/server/skel/fullstack/front/src/features/books/api.ts b/packages/server/skel/fullstack/front/src/features/books/api.ts new file mode 100644 index 00000000..b14c8b1f --- /dev/null +++ b/packages/server/skel/fullstack/front/src/features/books/api.ts @@ -0,0 +1,26 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { apiClient } from '@/lib/api-client'; + +import type { Book, BooksPage, CreateBook } from './types'; + +const keys = { + all: ['books'] as const, + list: (page: number) => ['books', { page }] as const, +}; + +export function useBooks(page = 1) { + return useQuery({ + queryKey: keys.list(page), + queryFn: () => apiClient.get(`/api/books?page=${page}`), + }); +} + +export function useCreateBook() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (book: CreateBook) => apiClient.post('/api/books', book), + onSuccess: () => queryClient.invalidateQueries({ queryKey: keys.all }), + }); +} diff --git a/packages/server/skel/fullstack/front/src/features/books/components/books-list.test.tsx b/packages/server/skel/fullstack/front/src/features/books/components/books-list.test.tsx new file mode 100644 index 00000000..5f593cbc --- /dev/null +++ b/packages/server/skel/fullstack/front/src/features/books/components/books-list.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { aBook } from '@/test/handlers'; + +import { BooksList } from './books-list'; + +// Un composant pur n'a besoin d'aucun provider : des props en entrée, du +// balisage en sortie. +describe('BooksList', () => { + it('affiche chaque livre', () => { + render(); + + expect(screen.getByText('Dune')).toBeInTheDocument(); + expect(screen.getByText('Neuromancer')).toBeInTheDocument(); + }); + + it("le dit quand il n'y a rien à afficher", () => { + render(); + + expect(screen.getByText(/aucun livre/i)).toBeInTheDocument(); + }); +}); diff --git a/packages/server/skel/fullstack/front/src/features/books/components/books-list.tsx b/packages/server/skel/fullstack/front/src/features/books/components/books-list.tsx new file mode 100644 index 00000000..2bdf16e5 --- /dev/null +++ b/packages/server/skel/fullstack/front/src/features/books/components/books-list.tsx @@ -0,0 +1,21 @@ +import type { Book } from '../types'; + +export function BooksList({ books }: { books: Book[] }) { + if (books.length === 0) { + return

Aucun livre pour l'instant.

; + } + + return ( +
    + {books.map((book) => ( +
  • +
    + {book.title} + {book.author} +
    + {book.pages} pages +
  • + ))} +
+ ); +} diff --git a/packages/server/skel/fullstack/front/src/features/books/pages/books-page.test.tsx b/packages/server/skel/fullstack/front/src/features/books/pages/books-page.test.tsx new file mode 100644 index 00000000..19f956cf --- /dev/null +++ b/packages/server/skel/fullstack/front/src/features/books/pages/books-page.test.tsx @@ -0,0 +1,90 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { describe, expect, it } from 'vitest'; + +import { renderWithProviders } from '@/test/render'; +import { server } from '@/test/msw-server'; + +import { BooksPage } from './books-page'; + +describe('BooksPage', () => { + it('affiche les livres une fois chargés', async () => { + renderWithProviders(); + + expect(screen.getByText(/chargement/i)).toBeInTheDocument(); + expect(await screen.findByText('Dune')).toBeInTheDocument(); + }); + + it('signale une erreur serveur au lieu de charger sans fin', async () => { + server.use( + http.get('/api/books', () => + HttpResponse.json( + { type: 'about:blank', title: 'Internal Server Error', status: 500 }, + { status: 500 }, + ), + ), + ); + + renderWithProviders(); + + expect(await screen.findByRole('alert')).toHaveTextContent(/internal server error/i); + }); + + it('affiche les erreurs de validation sous les champs nommés par le serveur', async () => { + server.use( + http.post('/api/books', () => + HttpResponse.json( + { + type: 'urn:igo:validation-failed', + title: 'Validation failed', + status: 400, + errors: [{ path: 'title', code: 'too_small', message: 'Too small' }], + }, + { status: 400 }, + ), + ), + ); + + renderWithProviders(); + await screen.findByText('Dune'); + + await userEvent.click(screen.getByRole('button', { name: /ajouter/i })); + + expect(await screen.findByText('Too small')).toBeInTheDocument(); + }); + + it('signale un échec qui ne vise aucun champ', async () => { + server.use( + http.post('/api/books', () => + HttpResponse.json( + { type: 'about:blank', title: 'Internal Server Error', status: 500 }, + { + status: 500, + headers: { traceresponse: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-00' }, + }, + ), + ), + ); + + renderWithProviders(); + await screen.findByText('Dune'); + + await userEvent.click(screen.getByRole('button', { name: /ajouter/i })); + + const alert = await screen.findByText(/l'ajout a échoué/i); + expect(alert).toHaveTextContent('0af7651916cd43dd8448eb211c80319c'); + }); + + it('ajoute un livre et rafraîchit la liste', async () => { + renderWithProviders(); + await screen.findByText('Dune'); + + await userEvent.type(screen.getByLabelText(/titre/i), 'Neuromancer'); + await userEvent.type(screen.getByLabelText(/auteur/i), 'Gibson'); + await userEvent.type(screen.getByLabelText(/pages/i), '271'); + await userEvent.click(screen.getByRole('button', { name: /ajouter/i })); + + await waitFor(() => expect(screen.getByLabelText(/titre/i)).toHaveValue('')); + }); +}); diff --git a/packages/server/skel/fullstack/front/src/features/books/pages/books-page.tsx b/packages/server/skel/fullstack/front/src/features/books/pages/books-page.tsx new file mode 100644 index 00000000..9d9c6450 --- /dev/null +++ b/packages/server/skel/fullstack/front/src/features/books/pages/books-page.tsx @@ -0,0 +1,32 @@ +import { useBooks } from '../api'; +import { BooksList } from '../components/books-list'; +import { AddBookSection } from '../sections/add-book-section'; + +// Une page assemble. Les états de chargement et d'erreur sont traités +// explicitement, plutôt que laissés à un indicateur qui tourne sans fin. +export function BooksPage() { + const { data, isPending, isError, error } = useBooks(); + + return ( + <> +

Livres

+ + + + {isPending &&

Chargement…

} + {isError && ( +

+ {error.message} +

+ )} + {data && ( + <> + +

{data.page.total} au total

+ + )} + + ); +} + +export const Component = BooksPage; diff --git a/packages/server/skel/fullstack/front/src/features/books/sections/add-book-section.tsx b/packages/server/skel/fullstack/front/src/features/books/sections/add-book-section.tsx new file mode 100644 index 00000000..8ee796c9 --- /dev/null +++ b/packages/server/skel/fullstack/front/src/features/books/sections/add-book-section.tsx @@ -0,0 +1,73 @@ +import { useState } from 'react'; + +import { ApiError } from '@/lib/api-client'; + +import { useCreateBook } from '../api'; + +const EMPTY = { title: '', author: '', pages: '' }; +const LABELS = { title: 'Titre', author: 'Auteur', pages: 'Pages' }; + +// Une section possède sa mutation. Le serveur est l'autorité sur la validité : +// ses erreurs par champ s'affichent telles quelles, sans être redérivées ici. +export function AddBookSection() { + const [form, setForm] = useState(EMPTY); + const createBook = useCreateBook(); + + const error = createBook.error instanceof ApiError ? createBook.error : null; + const fieldErrors = error?.problem.errors?.length ? error : null; + const globalError = createBook.isError && !fieldErrors ? createBook.error : null; + + const submit = (event: React.FormEvent) => { + event.preventDefault(); + createBook.mutate( + { title: form.title, author: form.author, pages: Number(form.pages) }, + { onSuccess: () => setForm(EMPTY) }, + ); + }; + + return ( +
+ {(['title', 'author', 'pages'] as const).map((field) => { + const message = fieldErrors?.fieldError(field); + return ( +
+ + setForm({ ...form, [field]: e.target.value })} + className="mt-1 w-full rounded border border-slate-300 px-3 py-2" + /> + {message && ( + + )} +
+ ); + })} + + {globalError && ( +

+ L'ajout a échoué : {globalError.message} + {error?.traceId && ( + Référence : {error.traceId} + )} +

+ )} + + +
+ ); +} diff --git a/packages/server/skel/fullstack/front/src/features/books/types.ts b/packages/server/skel/fullstack/front/src/features/books/types.ts new file mode 100644 index 00000000..dc5fdea8 --- /dev/null +++ b/packages/server/skel/fullstack/front/src/features/books/types.ts @@ -0,0 +1,22 @@ +// Reflète le DTO que le back sérialise, écrit à la main : le front ne dépend +// pas du build du back, et un écart se voit dans les tests de feature. +export interface Book { + id: number; + title: string; + author: string; + pages: number; + published: boolean; + createdAt: string; +} + +export interface BooksPage { + books: Book[]; + page: { page: number; perPage: number; pages: number; total: number }; +} + +export interface CreateBook { + title: string; + author: string; + pages: number; + published?: boolean; +} diff --git a/packages/server/skel/fullstack/front/src/index.css b/packages/server/skel/fullstack/front/src/index.css new file mode 100644 index 00000000..d4b50785 --- /dev/null +++ b/packages/server/skel/fullstack/front/src/index.css @@ -0,0 +1 @@ +@import 'tailwindcss'; diff --git a/packages/server/skel/fullstack/front/src/lib/api-client.ts b/packages/server/skel/fullstack/front/src/lib/api-client.ts new file mode 100644 index 00000000..c31f93d2 --- /dev/null +++ b/packages/server/skel/fullstack/front/src/lib/api-client.ts @@ -0,0 +1,64 @@ +// Document de problème RFC 9457, tel qu'igo le renvoie sur chaque erreur d'API. +export interface Problem { + type: string; + title: string; + status: number; + detail?: string; + errors?: { path: string; code?: string; message: string }[]; +} + +export class ApiError extends Error { + readonly problem: Problem; + + /** L'identifiant de trace renvoyé par le serveur, s'il en a renvoyé un. */ + readonly traceId?: string; + + constructor(problem: Problem, traceId?: string) { + super(problem.detail || problem.title); + this.name = 'ApiError'; + this.problem = problem; + this.traceId = traceId; + } + + /** Message for one field, to sit under the input that caused it. */ + fieldError(path: string): string | undefined { + return this.problem.errors?.find((e) => e.path === path)?.message; + } +} + +const TRACERESPONSE = /^[0-9a-f]{2}-([0-9a-f]{32})-[0-9a-f]{16}-[0-9a-f]{2}$/; + +// W3C Trace Context Level 2 définit `traceresponse` pour le retour. Faro ne +// l'exploite pas, on le lit donc à la main : seul le trace-id sert au support. +const traceIdFromResponse = (response: Response) => { + const header = response.headers.get('traceresponse'); + return (header && TRACERESPONSE.exec(header)?.[1]) || undefined; +}; + +// URL relatives à dessein : le même build tourne alors sur tous les +// environnements, derrière le proxy de développement ou derrière nginx. +const request = async (method: string, path: string, body?: unknown): Promise => { + const response = await fetch(path, { + method, + headers: body ? { 'Content-Type': 'application/json' } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + + if (!response.ok) { + const problem = await response.json().catch(() => ({ + type: 'about:blank', + title: response.statusText, + status: response.status, + })); + throw new ApiError(problem as Problem, traceIdFromResponse(response)); + } + + return response.status === 204 ? (undefined as T) : response.json(); +}; + +export const apiClient = { + get: (path: string) => request('GET', path), + post: (path: string, body: unknown) => request('POST', path, body), + put: (path: string, body: unknown) => request('PUT', path, body), + delete: (path: string) => request('DELETE', path), +}; diff --git a/packages/server/skel/fullstack/front/src/lib/query-client.ts b/packages/server/skel/fullstack/front/src/lib/query-client.ts new file mode 100644 index 00000000..379c66b7 --- /dev/null +++ b/packages/server/skel/fullstack/front/src/lib/query-client.ts @@ -0,0 +1,14 @@ +import { QueryClient } from '@tanstack/react-query'; + +import { ApiError } from './api-client'; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + // un 404 ou une erreur de validation ne se corrigera pas en réessayant + retry: (failureCount, error) => + !(error instanceof ApiError && error.problem.status < 500) && failureCount < 2, + }, + }, +}); diff --git a/packages/server/skel/fullstack/front/src/lib/report-error.ts b/packages/server/skel/fullstack/front/src/lib/report-error.ts new file mode 100644 index 00000000..3e7260da --- /dev/null +++ b/packages/server/skel/fullstack/front/src/lib/report-error.ts @@ -0,0 +1,24 @@ +import { faro } from '@grafana/faro-react'; + +import { ApiError } from './api-client'; + +// Une exception n'est rattachée à aucune trace : elle survient hors d'un span, +// et celui du fetch est déjà clos quand le catch s'exécute. +// +// `ApiError` porte le trace-id que le serveur a renvoyé dans `traceresponse` : +// c'est celui de la requête qui a échoué, donc exactement celui qu'on veut +// suivre. Faro n'a pas de span-id à y associer, un identifiant nul convient — +// seul le trace-id relie les deux bouts. +const NO_SPAN = '0000000000000000'; + +const spanContext = (error: unknown) => + error instanceof ApiError && error.traceId + ? { traceId: error.traceId, spanId: NO_SPAN } + : undefined; + +// Pour les erreurs que l'application rattrape et veut quand même voir remonter ; +// sans effet quand Faro n'est pas initialisé. +export const reportError = (error: unknown, context?: Record) => { + const asError = error instanceof Error ? error : new Error(String(error)); + faro.api?.pushError(asError, { context, spanContext: spanContext(error) }); +}; diff --git a/packages/server/skel/fullstack/front/src/main.tsx b/packages/server/skel/fullstack/front/src/main.tsx new file mode 100644 index 00000000..1ede1a70 --- /dev/null +++ b/packages/server/skel/fullstack/front/src/main.tsx @@ -0,0 +1,22 @@ +import './observability'; + +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { RouterProvider } from 'react-router'; + +import { ErrorBoundary } from '@/components/layout/error-boundary'; +import { queryClient } from '@/lib/query-client'; +import { router } from '@/routes'; + +import './index.css'; + +createRoot(document.getElementById('root')!).render( + + + + + + + , +); diff --git a/packages/server/skel/fullstack/front/src/observability.ts b/packages/server/skel/fullstack/front/src/observability.ts new file mode 100644 index 00000000..b75a4ca1 --- /dev/null +++ b/packages/server/skel/fullstack/front/src/observability.ts @@ -0,0 +1,155 @@ +import { + createReactRouterV7DataOptions, + getWebInstrumentations, + initializeFaro, + ReactIntegration, + TransportItemType, +} from '@grafana/faro-react'; +import type { TransportItem } from '@grafana/faro-react'; +import { TracingInstrumentation } from '@grafana/faro-web-tracing'; +import { matchRoutes } from 'react-router'; + +// Importé en premier dans main.tsx : Faro doit être en place avant React pour +// capter une erreur survenue au chargement. +// +// L'URL du collecteur part dans le navigateur — ce n'est pas un secret. Elle +// reste en variable d'environnement pour qu'un autre déploiement puisse viser +// ailleurs sans toucher au code. +// +// Son absence désactive Faro : c'est le défaut d'un poste de développement, qui +// ne doit ni consommer de quota ni mélanger ses erreurs à celles de la +// production. Pour l'activer le temps d'un build : +// VITE_FARO_URL=… pnpm build +const url = import.meta.env.VITE_FARO_URL; + +// Part du trafic en succès conservée. Une session sur dix suffit à mesurer des +// tendances de performance, et un incident touche rarement une seule session. +// Les erreurs, elles, échappent à ce tirage. +const ROUTINE_SHARE = Number(import.meta.env.VITE_FARO_SAMPLE ?? 0.1); + +// Tiré une fois par chargement : échantillonner événement par événement +// laisserait un parcours à moitié enregistré et rendrait les durées illisibles. +const inSample = Math.random() < ROUTINE_SHARE; + +// Un appel a échoué s'il a rendu un statut d'erreur — ou s'il n'a rendu aucun +// statut du tout. Un fetch qui échoue en réseau (DNS, expiration, CORS) n'a pas +// de `http.response.status_code` : le tester seul laisserait échantillonner +// l'événement le plus intéressant. +const failed = (attributes: Record | undefined) => { + const status = Number(attributes?.['http.response.status_code'] ?? 0); + if (status >= 400) { + return true; + } + // Un appel abouti porte toujours un statut ; son absence sur un événement de + // requête signale un échec avant la réponse. + const isRequest = + attributes?.['http.method'] !== undefined || + attributes?.['http.request.method'] !== undefined || + attributes?.['http.url'] !== undefined; + return isRequest && status === 0; +}; + +// Le contexte navigateur pèse ~1,5 Ko par événement, répété à chaque appel. +// Tout garder sature le quota sans rien apprendre ; tout jeter perdrait la +// corrélation front/back. On garde donc l'anormal en entier, et un échantillon +// du reste. +const filter = (item: TransportItem): TransportItem | null => { + switch (item.type) { + // Jamais échantillonnés. Une erreur vue une seule fois est précisément + // celle qu'on cherche, et les Web Vitals n'ont de sens qu'agrégés sur tout + // le trafic. + case TransportItemType.EXCEPTION: + case TransportItemType.MEASUREMENT: + return item; + + // Les spans du navigateur portent la racine de la trace. Les échantillonner + // ici ne laisserait que la partie serveur, et la corrélation front/back + // cesserait de fonctionner. La décision à l'échelle de la trace appartient + // au bit `sampled` de l'en-tête traceparent que le SDK propage, pas à ce + // filtre. + case TransportItemType.TRACE: + return item; + + // Un log volontaire — pushLog() — est intentionnel par nature : personne + // n'en écrit un sans raison, et la console n'est pas capturée. Le jeter + // même partiellement reviendrait à ignorer une demande explicite. + case TransportItemType.LOG: + return item; + + // Les événements — appels fetch, navigation, performance — font le volume : + // 93 % des charges Faro mesurées, à ~1,5 Ko de contexte navigateur chacun. + // C'est le seul signal à échantillonner, sauf quand l'appel a échoué. + case TransportItemType.EVENT: { + const payload = item.payload as { attributes?: Record }; + return failed(payload.attributes) || inSample ? item : null; + } + + // Un signal inconnu passe entier : on décide d'échantillonner, jamais + // l'inverse — c'est ainsi que les spans du navigateur ont été perdus une fois. + default: + return item; + } +}; + +if (url) { + initializeFaro({ + url, + app: { + // Doit correspondre exactement à l'application déclarée dans Grafana + // Frontend Observability, et à l'`appName` passé au téléversement des + // source maps (vite.config.ts) : c'est cette clé qui rattache une pile + // d'appels à ses source maps. + name: import.meta.env.VITE_FARO_APP_NAME || 'audit', + version: import.meta.env.VITE_APP_VERSION || '0.0.1', + // Pas import.meta.env.MODE : il vaut 'production' dans tout build Vite, y + // compris un `vite preview` sur un poste de développement. Les erreurs + // locales se mélangeraient alors à celles de la production. + environment: import.meta.env.VITE_ENVIRONMENT || 'dev', + }, + // Le collecteur refuse toute charge sans en-tête `X-Faro-Session-Id` : la + // session n'est pas optionnelle, seule sa persistance l'est. + // + // `persistent: false` garde l'identifiant en mémoire — il meurt avec + // l'onglet et n'est jamais écrit dans le navigateur, donc ce n'est pas un + // traceur au sens de l'article 82 de la loi Informatique et Libertés et + // aucun consentement n'est requis. Ce qu'on y perd : un rechargement ouvre + // une nouvelle session, ce qui fausse les durées et les parcours + // multi-pages. Erreurs, Web Vitals et corrélation front/back n'en + // dépendent pas. Un projet qui veut les parcours le persiste après son + // bandeau. + sessionTracking: { enabled: true, persistent: false }, + instrumentations: [ + ...getWebInstrumentations({ + // La console est ramassée indistinctement, bibliothèques tierces + // comprises. Ce qui mérite d'être remonté passe par reportError(). + captureConsole: false, + }), + new TracingInstrumentation(), + // Sans elle, une navigation est remontée sous son URL brute : /animaux/12 + // et /animaux/47 comptent alors comme deux pages distinctes, et + // l'agrégation par page devient illisible dès que les identifiants se + // multiplient. L'intégration résout le motif de la route, comme + // http.route le fait côté serveur. + // + // La variante « data router » n'a besoin que de matchRoutes ; c'est + // withFaroRouterInstrumentation, dans routes.tsx, qui l'abonne aux + // navigations. + new ReactIntegration({ + router: createReactRouterV7DataOptions({ matchRoutes }), + }), + ], + + beforeSend: filter, + + ignoreErrors: [ + // Bizarreries de mise en page, sans conséquence + /^ResizeObserver loop limit exceeded$/, + /^ResizeObserver loop completed with undelivered notifications$/, + // Scripts d'une autre origine, sans pile exploitable + /^Script error\.$/, + // Interférences d'extensions de navigateur + /chrome-extension:\/\//, + /moz-extension:\/\//, + ], + }); +} diff --git a/packages/server/skel/fullstack/front/src/routes.tsx b/packages/server/skel/fullstack/front/src/routes.tsx new file mode 100644 index 00000000..dc934cca --- /dev/null +++ b/packages/server/skel/fullstack/front/src/routes.tsx @@ -0,0 +1,23 @@ +import { withFaroRouterInstrumentation } from '@grafana/faro-react'; +import { createBrowserRouter } from 'react-router'; + +import { AppLayout } from '@/components/layout/app-layout'; +import { RouteError } from '@/components/layout/route-error'; + +const routes = createBrowserRouter([ + { + element: , + // Couvre tout l'arbre : react-router remonte l'erreur jusqu'au premier + // errorElement rencontré. + errorElement: , + children: [ + // lazy par feature : une route n'est téléchargée qu'à la visite + { index: true, lazy: () => import('@/features/books/pages/books-page') }, + ], + }, +]); + +// Abonne Faro aux navigations : l'intégration déclarée dans observability.ts en +// dépend pour résoudre le motif de chaque route. Sans collecteur configuré, +// l'appel est sans effet. +export const router = withFaroRouterInstrumentation(routes); diff --git a/packages/server/skel/fullstack/front/src/test/handlers.ts b/packages/server/skel/fullstack/front/src/test/handlers.ts new file mode 100644 index 00000000..841a3ea0 --- /dev/null +++ b/packages/server/skel/fullstack/front/src/test/handlers.ts @@ -0,0 +1,29 @@ +import { http, HttpResponse } from 'msw'; + +import type { Book } from '@/features/books/types'; + +export const aBook = (overrides: Partial = {}): Book => ({ + id: 1, + title: 'Dune', + author: 'Frank Herbert', + pages: 412, + published: true, + createdAt: '2026-01-01T00:00:00.000Z', + ...overrides, +}); + +// Les handlers par défaut décrivent le cas nominal ; un test surcharge avec +// server.use() le seul cas qui le concerne. +export const handlers = [ + http.get('/api/books', () => + HttpResponse.json({ + books: [aBook()], + page: { page: 1, perPage: 25, pages: 1, total: 1 }, + }), + ), + + http.post('/api/books', async ({ request }) => { + const body = (await request.json()) as Partial; + return HttpResponse.json(aBook({ id: 2, ...body }), { status: 201 }); + }), +]; diff --git a/packages/server/skel/fullstack/front/src/test/msw-server.ts b/packages/server/skel/fullstack/front/src/test/msw-server.ts new file mode 100644 index 00000000..5ac9204f --- /dev/null +++ b/packages/server/skel/fullstack/front/src/test/msw-server.ts @@ -0,0 +1,5 @@ +import { setupServer } from 'msw/node'; + +import { handlers } from './handlers'; + +export const server = setupServer(...handlers); diff --git a/packages/server/skel/fullstack/front/src/test/render.tsx b/packages/server/skel/fullstack/front/src/test/render.tsx new file mode 100644 index 00000000..c2f35d7d --- /dev/null +++ b/packages/server/skel/fullstack/front/src/test/render.tsx @@ -0,0 +1,14 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render } from '@testing-library/react'; +import type { ReactElement } from 'react'; + +// Un client neuf par test : un cache partagé entre les tests les fait passer ou +// échouer selon leur ordre. Les réessais sont coupés pour qu'une erreur remonte +// immédiatement. +export function renderWithProviders(ui: ReactElement) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + + return render({ui}); +} diff --git a/packages/server/skel/fullstack/front/src/test/setup.ts b/packages/server/skel/fullstack/front/src/test/setup.ts new file mode 100644 index 00000000..62812657 --- /dev/null +++ b/packages/server/skel/fullstack/front/src/test/setup.ts @@ -0,0 +1,14 @@ +import '@testing-library/jest-dom/vitest'; +import { cleanup } from '@testing-library/react'; +import { afterAll, afterEach, beforeAll } from 'vitest'; + +import { server } from './msw-server'; + +// MSW intercepte au niveau du réseau, donc le vrai apiClient tourne sans +// modification : remplacer l'enveloppe HTTP ne casse pas ces tests. +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +afterEach(() => { + server.resetHandlers(); + cleanup(); +}); +afterAll(() => server.close()); diff --git a/packages/server/skel/fullstack/front/src/vite-env.d.ts b/packages/server/skel/fullstack/front/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/packages/server/skel/fullstack/front/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/server/skel/fullstack/front/tsconfig.json b/packages/server/skel/fullstack/front/tsconfig.json new file mode 100644 index 00000000..46739c4f --- /dev/null +++ b/packages/server/skel/fullstack/front/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "types": ["vitest/globals", "@testing-library/jest-dom"], + "paths": { + "@/*": ["./src/*"] + }, + "moduleDetection": "force", + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "erasableSyntaxOnly": true + }, + "include": ["src", "vite.config.ts", "vitest.config.ts"] +} diff --git a/packages/server/skel/fullstack/front/vite.config.ts b/packages/server/skel/fullstack/front/vite.config.ts new file mode 100644 index 00000000..7117f441 --- /dev/null +++ b/packages/server/skel/fullstack/front/vite.config.ts @@ -0,0 +1,110 @@ +import { fileURLToPath, URL } from 'node:url'; + +import { defineConfig, loadEnv } from 'vite'; +import type { Plugin } from 'vite'; +import react from '@vitejs/plugin-react'; +import tailwindcss from '@tailwindcss/vite'; +import faroUploader from '@grafana/faro-rollup-plugin'; + +// La politique de sécurité de contenu vit ici, avec le code dont elle dépend : +// une police, un CDN ou une API tierce ajoutés au front s'ajoutent à cette +// liste, et la console le dit dès `pnpm dev`. Ce qu'une balise ne peut +// pas porter — frame-ancestors, HSTS — revient à nginx. +const contentSecurityPolicy = (dev: boolean, faroUrl?: string) => { + const faro = faroUrl ? ` ${new URL(faroUrl).origin}` : ''; + // en développement, Vite injecte le préambule React et les styles en ligne, + // et HMR parle en WebSocket + const inline = dev ? " 'unsafe-inline'" : ''; + return [ + "default-src 'self'", + `script-src 'self'${inline}`, + `style-src 'self'${inline}`, + "img-src 'self' data:", + "font-src 'self'", + `connect-src 'self'${faro}${dev ? ' ws:' : ''}`, + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + ].join('; '); +}; + +const csp = (dev: boolean, faroUrl?: string): Plugin => ({ + name: 'content-security-policy', + transformIndexHtml: () => [ + { + tag: 'meta', + attrs: { + 'http-equiv': 'Content-Security-Policy', + content: contentSecurityPolicy(dev, faroUrl), + }, + injectTo: 'head-prepend', + }, + ], +}); + +export default defineConfig(({ mode, command }) => { + // vite.config.ts ne reçoit pas le .env dans process.env : loadEnv le lit + // explicitement. Le troisième argument vide lève le filtre sur le préfixe + // VITE_, sans quoi une clé qui ne sert qu'au build resterait invisible ici. + const env = { ...loadEnv(mode, process.cwd(), ''), ...process.env }; + + const API_PROXY = { + target: env.API_URL || 'http://127.0.0.1:3000', + changeOrigin: false, + }; + + // Les source maps ne sont téléversées que si la clé est fournie : un build + // sans observabilité configurée reste possible, et la CI d'une contribution + // externe n'a pas besoin du secret. Les quatre valeurs viennent de la page de + // réglages de l'application Grafana. + const faro = + env.FARO_API_KEY && env.FARO_APP_ID && env.FARO_STACK_ID && env.FARO_UPLOAD_ENDPOINT + ? faroUploader({ + appName: env.VITE_FARO_APP_NAME || 'audit', + endpoint: env.FARO_UPLOAD_ENDPOINT, + appId: env.FARO_APP_ID, + stackId: env.FARO_STACK_ID, + apiKey: env.FARO_API_KEY, + gzipContents: true, + }) + : null; + + return { + plugins: [ + react(), + tailwindcss(), + csp(command === 'serve', env.VITE_FARO_URL), + ...(faro ? [faro] : []), + ], + + // Les chemins du tsconfig ne servent qu'au vérificateur de types : le + // bundler a besoin des siens + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + + build: { + // Sans source maps, une pile d'appels dans Grafana désigne du code + // minifié. `hidden` les produit sans que le bundle y renvoie : le + // téléversement les donne à Grafana, le navigateur ne les télécharge + // jamais. + sourcemap: 'hidden', + }, + + // Le navigateur ne voit qu'une seule origine, donc le cookie de session + // d'igo passe comme n'importe quel cookie de même origine — pas de CORS, pas + // de gestion d'identifiants. En production, nginx joue ce rôle. + server: { + port: 5173, + proxy: { '/api': API_PROXY }, + }, + + // `vite preview` n'hérite pas de server.proxy : sans ceci, un build servi + // pour les tests E2E n'aurait aucune API derrière lui. + preview: { + proxy: { '/api': API_PROXY }, + }, + }; +}); diff --git a/packages/server/skel/fullstack/front/vitest.config.ts b/packages/server/skel/fullstack/front/vitest.config.ts new file mode 100644 index 00000000..5cfd273a --- /dev/null +++ b/packages/server/skel/fullstack/front/vitest.config.ts @@ -0,0 +1,22 @@ +import { defineConfig, mergeConfig } from 'vitest/config'; + +import viteConfig from './vite.config.ts'; + +// Vitest 5 n'accepte plus de clé `test` dans le defineConfig de vite : la +// configuration des tests vit dans son propre fichier et réutilise celle de +// l'application. +// +// vite.config.ts est une fonction depuis qu'il lit le .env : on la résout ici en +// mode test, ce qui laisse au passage le téléversement des source maps +// désactivé pendant les tests. +export default mergeConfig( + viteConfig({ mode: 'test', command: 'serve' }), + defineConfig({ + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/test/setup.ts'], + include: ['src/**/*.{test,spec}.{ts,tsx}'], + }, + }), +); diff --git a/packages/server/skel/fullstack/package.json b/packages/server/skel/fullstack/package.json new file mode 100644 index 00000000..4d4deaf0 --- /dev/null +++ b/packages/server/skel/fullstack/package.json @@ -0,0 +1,32 @@ +{ + "name": "{project.name}", + "version": "0.0.1", + "private": true, + "type": "module", + "packageManager": "pnpm@11.1.3", + "scripts": { + "dev": "concurrently -n api,front -c blue,magenta \"pnpm --filter ./api start\" \"pnpm --filter ./front dev\"", + "build": "pnpm --filter ./api build && pnpm --filter ./front build", + "lint": "pnpm -r lint", + "typecheck": "pnpm -r typecheck", + "test": "pnpm -r test", + "test:e2e": "pnpm --filter ./e2e test:e2e", + "migrate": "pnpm --filter ./api exec igo db migrate", + "seed": "pnpm --filter ./api seed", + "prepare": "husky", + "format": "oxfmt", + "format:check": "oxfmt --check" + }, + "devDependencies": { + "@commitlint/cli": "^21.2.0", + "@commitlint/config-conventional": "^21.2.0", + "concurrently": "^10.0.0", + "husky": "^9.1.7", + "lint-staged": "^17.5.0", + "oxfmt": "^0.66.0", + "oxlint": "^1.81.0" + }, + "engines": { + "node": ">=24" + } +} diff --git a/packages/server/skel/fullstack/pnpm-workspace.yaml b/packages/server/skel/fullstack/pnpm-workspace.yaml new file mode 100644 index 00000000..a4fb7f06 --- /dev/null +++ b/packages/server/skel/fullstack/pnpm-workspace.yaml @@ -0,0 +1,13 @@ +packages: + - api + - front + - e2e + +# pnpm blocks postinstall scripts unless a package is listed here. These +# compile or download a native binary, which they need to run at all. +allowBuilds: + esbuild: true + '@parcel/watcher': true + msw: true + # arrives with the OpenTelemetry OTLP exporter + protobufjs: true diff --git a/packages/server/src/api/handler.d.ts b/packages/server/src/api/handler.d.ts new file mode 100644 index 00000000..fbcfdecb --- /dev/null +++ b/packages/server/src/api/handler.d.ts @@ -0,0 +1,47 @@ +import type { Request, Response, NextFunction } from 'express'; +import type { ParsedQs } from 'qs'; +import type { StandardSchemaV1 } from '@standard-schema/spec'; + +type Infer = S extends StandardSchemaV1 ? StandardSchemaV1.InferOutput : never; + +/** + * The query type is intersected with ParsedQs: Express takes the handlers of a + * route in one rest parameter, so a plain middleware (typed ParsedQs) and a + * handler typed by its schema must share a query type, or no overload matches. + * The cost: ParsedQs has an index signature, so reading a field absent from + * the schema is not a compile error — only its type is. + */ + +/** + * An API handler whose request is shaped by the schemas attached to it. + * + * const create: ApiHandler<{ body: typeof CreateBook }> = (req, res) => { + * req.body.pages; // number, coerced and validated + * }; + * create.body = CreateBook; + * + * The schemas are read by igo at boot: declaring them here only mirrors, for + * the type checker, what the runtime already does. + */ +export interface ApiHandler< + Schemas extends { + body?: StandardSchemaV1; + query?: StandardSchemaV1; + params?: StandardSchemaV1; + } = {} +> { + ( + req: Request< + Schemas['params'] extends StandardSchemaV1 ? Infer : Record, + unknown, + Schemas['body'] extends StandardSchemaV1 ? Infer : unknown, + Schemas['query'] extends StandardSchemaV1 ? Infer & ParsedQs : ParsedQs + >, + res: Response, + next: NextFunction + ): void | Promise; + + body?: Schemas['body']; + query?: Schemas['query']; + params?: Schemas['params']; +} diff --git a/packages/server/src/api/index.js b/packages/server/src/api/index.js new file mode 100644 index 00000000..dbd69141 --- /dev/null +++ b/packages/server/src/api/index.js @@ -0,0 +1,43 @@ + +const config = require('../config'); +const logger = require('../logger'); +const problem = require('./problem'); +const validate = require('./validate'); +const { isApiRequest } = require('./request'); + +const mounted = []; + +// app.api('/dossiers', router) mounts under config.api.prefix. igo owns the +// prefix so a project never repeats it, and knows which routers are APIs — +// Express 5 keeps a mount path only as an opaque matcher. +module.exports.init = (app) => { + mounted.length = 0; + + app.api = (path, ...handlers) => { + const mountPath = config.api.prefix + path; + mounted.push({ path: mountPath, router: handlers[handlers.length - 1] }); + app.use(mountPath, ...handlers); + return app; + }; +}; + +// Called once every route is mounted: wraps the handlers that declare a schema +// and reports the API routes that take a body without one. +module.exports.wire = () => { + const unvalidated = mounted.flatMap(({ path, router }) => + validate.apply(router).map(route => `${path}${route}`) + ); + + if (unvalidated.length) { + logger.warn(`igo: ${unvalidated.length} API route(s) without validation schema (${unvalidated.join(', ')})`); + } +}; + +// Wraps a middleware that only serves rendered pages — flash scope, view +// locals, asset manifest — so an API request skips it. What it skips is not +// only wasted work: the flash scope writes to the session on every GET, and +// that alone made every JSON response set a session cookie nothing reads. +module.exports.unlessApi = (middleware) => (req, res, next) => + isApiRequest(req) ? next() : middleware(req, res, next); + +module.exports.problem = problem; diff --git a/packages/server/src/api/problem.d.ts b/packages/server/src/api/problem.d.ts new file mode 100644 index 00000000..b11c4038 --- /dev/null +++ b/packages/server/src/api/problem.d.ts @@ -0,0 +1,35 @@ +import type { Request, Response } from 'express'; + +/** RFC 9457 Problem Details document. */ +export interface ProblemDocument { + type: string; + title: string; + status: number; + detail?: string; + errors?: ProblemError[]; +} + +export interface ProblemError { + /** Dotted path of the offending field, e.g. 'tags.0'. */ + path: string; + /** Stable identifier to branch on, e.g. 'invalid_type'. Absent if the schema library does not provide one. */ + code?: string; + /** Human-readable text; wording changes with the schema library. */ + message: string; +} + +export interface ProblemOptions { + type?: string; + title?: string; + detail?: string; + errors?: ProblemError[]; +} + +export declare const CONTENT_TYPE: 'application/problem+json'; + +/** `type` of the problem igo returns when a schema rejects a request. */ +export declare const VALIDATION_FAILED: 'urn:igo:validation-failed'; + +export declare function problem(status: number, options?: ProblemOptions): ProblemDocument; + +export declare function send(res: Response, status: number, options?: ProblemOptions): Response; diff --git a/packages/server/src/api/problem.js b/packages/server/src/api/problem.js new file mode 100644 index 00000000..8648112c --- /dev/null +++ b/packages/server/src/api/problem.js @@ -0,0 +1,35 @@ + +const { STATUS_CODES } = require('http'); + + +const CONTENT_TYPE = 'application/problem+json'; + +// igo produces one problem specific enough to name: everything else is +// identified by its status alone. A URN rather than the /problems/ form +// suggested to applications — a relative URI would resolve differently on every +// project, and would compete with the slugs the application defines. +const VALIDATION_FAILED = 'urn:igo:validation-failed'; + +// RFC 9457 Problem Details +const problem = (status, { title, detail, errors, type } = {}) => { + const body = { + type: type || 'about:blank', + title: title || STATUS_CODES[status] || 'Error', + status, + }; + if (detail) { + body.detail = detail; + } + if (errors) { + body.errors = errors; + } + return body; +}; + +const send = (res, status, options) => { + res.status(status); + res.setHeader('Content-Type', CONTENT_TYPE); + return res.json(problem(status, options)); +}; + +module.exports = { problem, send, CONTENT_TYPE, VALIDATION_FAILED }; diff --git a/packages/server/src/api/request.d.ts b/packages/server/src/api/request.d.ts new file mode 100644 index 00000000..f869f424 --- /dev/null +++ b/packages/server/src/api/request.d.ts @@ -0,0 +1,7 @@ +import type { Request } from 'express'; + +/** + * Whether a request is served as JSON — under `config.api.prefix`, or from a + * client whose Accept header asks for it. + */ +export declare function isApiRequest(req: Pick): boolean; diff --git a/packages/server/src/api/request.js b/packages/server/src/api/request.js new file mode 100644 index 00000000..c927736b --- /dev/null +++ b/packages/server/src/api/request.js @@ -0,0 +1,17 @@ + +const config = require('../config'); + +// Whether a request is served as JSON — under the API prefix, or from a client +// that asked for JSON and cannot render a dust page anyway. +// +// The question is one of routing, not of error format: the security headers, +// `unlessApi()`, the 404 and the error handler all ask it, and none of them is +// about problem documents. +module.exports.isApiRequest = (req) => { + const prefix = config.api?.prefix; + const path = req.path || req.url || ''; + if (prefix && (path === prefix || path.startsWith(prefix + '/'))) { + return true; + } + return !!req.headers?.accept?.includes('application/json'); +}; diff --git a/packages/server/src/api/validate.js b/packages/server/src/api/validate.js new file mode 100644 index 00000000..0b893bbb --- /dev/null +++ b/packages/server/src/api/validate.js @@ -0,0 +1,113 @@ + +const problem = require('./problem'); + +const SOURCES = ['body', 'query', 'params']; +const WITH_BODY = ['post', 'put', 'patch']; + +// Any Standard Schema implementation (zod, valibot, arktype) exposes ~standard. +const isSchema = (value) => + !!value && (typeof value === 'object' || typeof value === 'function') && '~standard' in value; + +// Schemas are attached to the handler itself: if the handler is mounted, its +// validation is too — no name to keep in sync, nothing to write in the routes. +// exports.create.body = dto.CreerDossier; +const schemasOf = (handler) => { + if (typeof handler !== 'function') { + return null; + } + let schemas = null; + for (const source of SOURCES) { + if (isSchema(handler[source])) { + schemas = schemas || {}; + schemas[source] = handler[source]; + } + } + return schemas; +}; + +// Express 5 exposes req.query through a getter: assigning to it fails silently. +const replace = (req, source, value) => { + if (source === 'query') { + Object.defineProperty(req, 'query', { value, writable: true, configurable: true }); + return; + } + req[source] = value; +}; + +// `message` is meant for humans and changes with the schema library's version +// and locale; `code` is the stable identifier a client should branch on. It is +// a Zod extra rather than a Standard Schema guarantee, hence the check. +const issuesOf = (result) => result.issues.map((issue) => { + const error = { + path: (issue.path || []).map(segment => segment?.key ?? segment).join('.'), + }; + if (issue.code) { + error.code = issue.code; + } + error.message = issue.message; + return error; +}); + +// Wraps a handler so its schemas are applied before it runs. +const wrap = (handler, schemas) => { + const validated = async (req, res, next) => { + try { + for (const [source, schema] of Object.entries(schemas)) { + const result = await schema['~standard'].validate(req[source]); + if (result.issues) { + return problem.send(res, 400, { + type: problem.VALIDATION_FAILED, + title: 'Validation failed', + errors: issuesOf(result), + }); + } + replace(req, source, result.value); + } + } catch (err) { + return next(err); + } + return handler(req, res, next); + }; + Object.assign(validated, handler); + return validated; +}; + +const eachRoute = (router, fn) => { + for (const layer of router.stack || []) { + if (layer.route) { + fn(layer.route); + } else if (layer.handle?.stack) { + eachRoute(layer.handle, fn); + } + } +}; + +// Walks an API router once at boot and wraps every handler that declares a +// schema. Returns the routes that take a body without declaring one. +module.exports.apply = (router) => { + const unvalidated = []; + + eachRoute(router, (route) => { + let validatedRoute = false; + + route.stack.forEach((layer) => { + const schemas = schemasOf(layer.handle); + if (schemas) { + layer.handle = wrap(layer.handle, schemas); + validatedRoute = true; + } + }); + + if (validatedRoute) { + return; + } + Object.keys(route.methods) + .filter(method => WITH_BODY.includes(method)) + .forEach(method => unvalidated.push(`${method.toUpperCase()} ${route.path}`)); + }); + + return unvalidated; +}; + +module.exports.schemasOf = schemasOf; +module.exports.isSchema = isSchema; diff --git a/packages/server/src/app.js b/packages/server/src/app.js index 29a59fb7..706a8f15 100644 --- a/packages/server/src/app.js +++ b/packages/server/src/app.js @@ -11,10 +11,14 @@ const cache = require('./cache'); const config = require('./config'); const db = require('@igojs/db'); const assets = require('./connect/assets'); +const { unlessApi } = require('./api'); const errorHandler = require('./connect/errorhandler'); const flash = require('./connect/flash'); +const health = require('./connect/health'); const locals = require('./connect/locals'); const multipart = require('./connect/multipart'); +const requestLogger = require('./connect/requestlogger'); +const securityHeaders = require('./connect/security'); const session = require('./connect/session'); const validator = require('./connect/validator'); const logger = require('./logger'); @@ -73,6 +77,7 @@ module.exports.configure = async () => { app.enable('trust proxy'); app.disable('x-powered-by'); + app.use(securityHeaders); // Enable view caching in production if (config.env === 'production') { @@ -110,7 +115,12 @@ module.exports.configure = async () => { } - app.use(flash); + // before the request logger: probed every few seconds, these routes would + // otherwise be most of the request log + health(app); + + app.use(requestLogger); + app.use(unlessApi(flash)); app.use(validator); // fix crash if lang is incorrect (in query or in cookies) @@ -119,9 +129,9 @@ module.exports.configure = async () => { app.use(validateLang(whitelist, config.i18n.fallbackLng)); app.use(i18nMiddleware.handle(i18next)); - app.use(locals); - app.use(assets); - app.use(igodust.middleware); + app.use(unlessApi(locals)); + app.use(unlessApi(assets)); + app.use(unlessApi(igodust.middleware)); // Auto-wire @igojs/component if installed in the project. // Registers component.middleware + GET /__component/templates and /__component/component. diff --git a/packages/server/src/config.js b/packages/server/src/config.js index 9b405d01..33c6e6ea 100644 --- a/packages/server/src/config.js +++ b/packages/server/src/config.js @@ -3,12 +3,64 @@ if (process.env.NODE_ENV !== 'production') { require('dotenv').config({ quiet: true }); } +const path = require('path'); + const config = {}; module.exports = config; const DEFAULT_COOKIE_SECRET = 'abcdefghijklmnopqrstuvwxyz'; const DEFAULT_SESSION_KEY = 'aaaaaaaaaaa'; +// The nearest package.json at or above projectRoot: a build directory (dist/) +// has none of its own, and a project without one at all still has to boot. +const readProjectPackage = (projectRoot) => { + let dir = path.resolve(projectRoot); + for (;;) { + try { + return require(path.join(dir, 'package.json')); + } catch { + const parent = path.dirname(dir); + if (parent === dir) { + return {}; + } + dir = parent; + } + } +}; + +// Computed on first access rather than at init(), then cached: projectRoot can +// still be reassigned after init(), and a value set by the application wins. +const defineLazy = (target, property, compute) => { + const settle = (value) => Object.defineProperty(target, property, { + value, writable: true, configurable: true, enumerable: true + }); + Object.defineProperty(target, property, { + configurable: true, + enumerable: true, + get() { + const value = compute(); + settle(value); + return value; + }, + set: settle, + }); +}; + +const defineProjectValue = (target, property, override, packageKey) => + defineLazy(target, property, () => override || readProjectPackage(target.projectRoot)[packageKey]); + +// LOG_REQUESTS=true|false|; anything else keeps the default. +const parseLogRequests = (value, fallback) => { + if (value === 'true' || value === 'false') { + return value === 'true'; + } + const floor = Number(value); + return value && Number.isInteger(floor) && floor > 0 ? floor : fallback; +}; + +module.exports.parseLogRequests = parseLogRequests; +module.exports.readProjectPackage = readProjectPackage; + // module.exports.init = function() { @@ -21,6 +73,12 @@ module.exports.init = function() { config.httpport = process.env.HTTP_PORT || 3000; config.projectRoot = process.cwd(); + // Identifies the app in crash emails and in every log line, which is what + // tells one project and one environment apart once logs are pooled. + // Resolved on read: projectRoot can still be reassigned after init(). + defineProjectValue(config, 'appname', process.env.APP_NAME, 'name'); + defineProjectValue(config, 'version', process.env.APP_VERSION, 'version'); + config.cookieSecret = process.env.COOKIE_SECRET || DEFAULT_COOKIE_SECRET; config.cookieSession = { name: 'app', @@ -32,6 +90,43 @@ module.exports.init = function() { config.urlencoded = { limit: '10mb', extended: true }; config.json = { limit: '10mb' }; + // routes under this prefix answer in JSON, never in HTML + config.api = { prefix: '/api' }; + + // Security headers on every response. `false` on a key drops that header, + // `config.security = false` drops them all. `csp` is for the pages and left to + // the project; `apiCsp` and `apiCacheControl` apply to API requests. + config.security = { + noSniff: true, + frameOptions: 'SAMEORIGIN', + referrerPolicy: 'strict-origin-when-cross-origin', + permissionsPolicy: 'camera=(), microphone=(), geolocation=()', + hsts: 'max-age=63072000; includeSubDomains', + csp: null, + apiCsp: 'default-src \'none\'; frame-ancestors \'none\'', + apiCacheControl: 'no-store', + }; + + // Liveness on `path`, readiness on `path`/ready — the latter probes the + // dependencies and answers 503 when one is down, which is what takes the + // instance out of a load balancer. `false` drops both routes. + // A probe set to 'optional' is reported but never brings readiness down: the + // cache is gone, igo serves without it, and the instance stays in rotation. + // Anything else truthy is critical. + config.health = { + path: '/health', + db: true, + cache: 'optional', + // free bytes below which the instance can no longer write its logs and its + // uploads, and has to leave the rotation + disk: 50 * 1024 * 1024, + timeout: 500, + }; + + // set to false to keep serving after an uncaught exception that a request + // already answered — only once alerting no longer relies on the crash email + config.exitOnUncaughtException = true; + config.i18n = { whitelist: [ 'en', 'fr' ], preload: [ 'en', 'fr' ], @@ -108,6 +203,18 @@ module.exports.init = function() { // logger config.loglevel = process.env.LOG_LEVEL || 'info'; + // 'json' for log collectors, 'human' for a terminal + config.logformat = process.env.LOG_FORMAT || (config.env === 'production' ? 'json' : 'human'); + // true logs every request, false none. A number is a status floor: 400 keeps + // the errors and drops the successes, which is what keeps a log bill down + // once latency and error rate come from metrics. A deployment setting, like + // the format, hence LOG_REQUESTS. + config.logrequests = parseLogRequests(process.env.LOG_REQUESTS, config.env !== 'test'); + + // Keys whose value redact() replaces; null keeps the default of src/redact.js. + // A pattern set here replaces it, so extend redact.DEFAULT_SENSITIVE_KEYS: + // config.sensitiveKeys = new RegExp(`${redact.DEFAULT_SENSITIVE_KEYS.source}|iban`, 'i'); + config.sensitiveKeys = null; // if (config.env === 'dev') { diff --git a/packages/server/src/connect/errorhandler.js b/packages/server/src/connect/errorhandler.js index 34650253..c134a156 100644 --- a/packages/server/src/connect/errorhandler.js +++ b/packages/server/src/connect/errorhandler.js @@ -17,12 +17,16 @@ * - Logs error and sends email notification * - Forces process.exit(1) after 1 second * - Process manager (PM2, systemd) will restart the server + * - config.exitOnUncaughtException = false keeps the server alive when the + * request was already answered (never outside a request context) * * Special cases: * - URIError (malformed URL): returns 404 - * - SyntaxError (invalid JSON): returns 500 + * - SyntaxError (invalid JSON): returns 500, or 400 on an API request * - Both are client errors and don't trigger email notifications * + * An API request gets an RFC 9457 document, never a rendered dust page. + * * Email throttling: * - To prevent email spam during crash loops, emails are throttled per error type * - If the same error triggers 3+ emails within 1 minute, that error is blocked for 5 minutes @@ -38,8 +42,11 @@ const path = require('path'); const os = require('os'); const config = require('../config'); +const redact = require('../redact'); const logger = require('../logger'); const mailer = require('../mailer'); +const problem = require('../api/problem'); +const { isApiRequest } = require('../api/request'); const asyncLocalStorage = new AsyncLocalStorage(); @@ -85,7 +92,6 @@ const checkThrottle = (errorKey) => { } } - // Check if this error is currently blocked if (data.blocked[errorKey] && data.blocked[errorKey] > now) { saveThrottleData(data); return { throttled: true, shouldAlert: false }; @@ -111,19 +117,6 @@ const checkThrottle = (errorKey) => { const HTML_ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', '\'': ''' }; const escapeHtml = (s) => String(s).replace(/[&<>"']/g, c => HTML_ESCAPES[c]); -// credentials must not leak in crash emails -const SENSITIVE_KEYS = /cookie|authorization|password|token|secret/i; -const redact = (obj) => { - if (!obj || typeof obj !== 'object') { - return obj; - } - const copy = Array.isArray(obj) ? [] : {}; - for (const key in obj) { - copy[key] = SENSITIVE_KEYS.test(key) ? '[redacted]' : redact(obj[key]); - } - return copy; -}; - const getURL = (req) => { const protocol = req.protocol || 'http'; const host = req.headers['x-forwarded-host'] || (req.get ? req.get('host') : req.headers.host) || 'localhost'; @@ -182,12 +175,14 @@ const sendCrashEmail = (subject, body, errorKey) => { }); }; -// Handle errors that occur during HTTP requests const handle = (err, req, res) => { + // an API client cannot render a dust page: it always gets JSON back + const isApi = isApiRequest(req); + // Client errors - don't send emails if (err instanceof URIError) { if (!res.headersSent) { - res.status(404).render('errors/404'); + isApi ? problem.send(res, 404) : res.status(404).render('errors/404'); } return; } @@ -195,28 +190,32 @@ const handle = (err, req, res) => { // body-parser JSON only; other SyntaxErrors fall through to logging. if (err instanceof SyntaxError && err.type === 'entity.parse.failed') { if (!res.headersSent) { - res.status(500).render('errors/500'); + // malformed JSON is the client's mistake, and only an API client sends it + if (isApi) { + problem.send(res, 400, { detail: 'Malformed JSON body' }); + } else { + res.status(500).render('errors/500'); + } } return; } - // Check if response already sent if (res.headersSent) { - // Response already sent, can only log - logger.error(`${req.method} ${getURL(req)} : ${err} (response already sent)`); - logger.error(err.stack); + logger.error(`${req.method} ${getURL(req)} : ${err} (response already sent)`, + { stack: err.stack }); sendCrashEmail(`Crash (response sent): ${err}`, formatMessage(req, err), String(err)); return; } - // Log error - logger.error(`${req.method} ${getURL(req)} : ${err}`); - logger.error(err.stack); + logger.error(`${req.method} ${getURL(req)} : ${err}`, { stack: err.stack }); - // Send email notification sendCrashEmail(`Crash: ${err}`, formatMessage(req, err), String(err)); - // Send response + if (isApi) { + // the stack is a debugging aid outside production, never a client contract + return problem.send(res, 500, config.env === 'production' ? {} : { detail: err.message }); + } + if (config.env === 'production') { return res.status(500).render('errors/500'); } @@ -228,8 +227,17 @@ const handle = (err, req, res) => { res.status(500).send(stacktrace); }; -// Handle unhandled promise rejections +// A CLI command has no request to answer and no server to keep alive: one line +// saying what failed, then exit. +const failCli = (err) => { + console.error(`\x1b[31m✖\x1b[0m ${err?.message || err}`); + process.exit(1); +}; + process.on('unhandledRejection', (err) => { + if (global.IGO_CLI) { + return failCli(err); + } const context = asyncLocalStorage.getStore(); if (context && context.req && context.res) { @@ -242,18 +250,30 @@ process.on('unhandledRejection', (err) => { } }); -// Handle uncaught exceptions - log, send email, then exit +// L'email part avant la sortie : l'inverse perdrait l'alerte. process.on('uncaughtException', (err) => { + if (global.IGO_CLI) { + return failCli(err); + } const context = asyncLocalStorage.getStore(); + const handled = !!(context && context.req && context.res); - if (context && context.req && context.res) { + if (handled) { handle(err, context.req, context.res); } else { - logger.error('Uncaught exception outside of request context:', err); - logger.error(err.stack); + logger.error(`Uncaught exception outside of request context: ${err}`, + { stack: err.stack }); sendCrashEmail(`Uncaught exception: ${err}`, `
${escapeHtml(err.stack)}
`, String(err)); } + // Node makes no promise about the state of a process that reached this point, + // so restarting is the safe default. A request that was handled and answered + // is the case worth keeping alive, once alerting no longer relies on the + // crash email to notice the error. + if (config.exitOnUncaughtException === false && handled) { + return; + } + // Exit after a short delay to allow email to be sent setTimeout(() => { process.exit(1); @@ -270,7 +290,6 @@ module.exports.initContext = (app) => { }; }; -// Get current request context module.exports.getContext = () => { return asyncLocalStorage.getStore(); }; diff --git a/packages/server/src/connect/health.js b/packages/server/src/connect/health.js new file mode 100644 index 00000000..c039be13 --- /dev/null +++ b/packages/server/src/connect/health.js @@ -0,0 +1,108 @@ + +const { statfs } = require('fs/promises'); + +const cache = require('../cache'); +const config = require('../config'); +const db = require('@igojs/db'); +const logger = require('../logger'); + +const UP = 'UP'; +const DOWN = 'DOWN'; + +// A probe that hangs must not hold the answer: an orchestrator that waits is an +// orchestrator that keeps routing traffic to an instance already in trouble. +const withTimeout = (promise, ms) => Promise.race([ + promise, + new Promise((resolve, reject) => setTimeout(() => reject(new Error(`timeout after ${ms}ms`)), ms)), +]); + +const probeDb = async () => { + await db.dbs.main.query('SELECT 1', [], { silent: true }); +}; + +// The cache module already tracks the connection: it reconnects on its own and +// reports a degraded state, so asking it is both cheaper and more accurate than +// a PING that would race with its reconnection. +const probeCache = async () => { + if (!cache.isAvailable()) { + throw new Error('redis is not available'); + } +}; + +// The disk the application writes to, not the root: on a host with separate +// partitions `/` stays healthy while the one holding the logs and the uploads +// fills up. +const probeDisk = async (threshold) => { + const { bsize, bavail } = await statfs(config.projectRoot); + const free = bsize * bavail; + if (free < threshold) { + throw new Error(`${free} bytes free, below ${threshold}`); + } +}; + +// CPU and memory are deliberately absent: a saturated CPU is often an instance +// doing its job, and taking it out would spread the load onto the others. They +// belong to alerting, where a trend is read, not to a probe that decides in +// isolation. +const PROBES = { + db: probeDb, + cache: probeCache, + disk: probeDisk, +}; + +const runProbe = async (name, setting, timeout) => { + try { + await withTimeout(PROBES[name](setting), timeout); + return UP; + } catch (err) { + // The reason stays in the logs: /health/ready is reachable by whoever can + // reach the service, and a connection error names hosts and ports. + logger.warn('health: %s is down', name, { error: err.message }); + return DOWN; + } +}; + +const send = (res, status, body) => { + res.status(status).type('application/json').json(body); +}; + +const liveness = (req, res) => { + send(res, 200, { status: UP }); +}; + +// A dependency the application cannot serve without brings readiness down; +// one it merely runs better with is reported and nothing more. Sending 503 +// because the cache is gone would take an instance that still serves out of +// the load balancer — a degradation turned into an outage. +const isOptional = (setting) => setting === 'optional'; + +const readiness = (settings) => async (req, res) => { + const names = Object.keys(PROBES).filter(name => settings[name]); + const states = await Promise.all( + names.map(name => runProbe(name, settings[name], settings.timeout))); + + const components = {}; + names.forEach((name, i) => { + components[name] = { status: states[i] }; + }); + + const up = states.every((state, i) => + state === UP || isOptional(settings[names[i]])); + // 503 and not 500: the service did not fail, it is not ready to serve. Load + // balancers only read the status code, so this is what takes the instance out + // of rotation. + send(res, up ? 200 : 503, { status: up ? UP : DOWN, components }); +}; + +// Mounted by igo before the request logger, which is what keeps these routes +// out of the request log: probed every few seconds, they would otherwise be +// most of it. +module.exports = (app) => { + const settings = config.health; + if (!settings) { + return; + } + + app.get(settings.path, liveness); + app.get(`${settings.path}/ready`, readiness(settings)); +}; diff --git a/packages/server/src/connect/requestlogger.js b/packages/server/src/connect/requestlogger.js new file mode 100644 index 00000000..fae2df96 --- /dev/null +++ b/packages/server/src/connect/requestlogger.js @@ -0,0 +1,168 @@ + +const { AsyncLocalStorage } = require('async_hooks'); +const { randomBytes } = require('crypto'); + +const config = require('../config'); +const logger = require('../logger'); +const redact = require('../redact'); + +const storage = new AsyncLocalStorage(); + +// Optional: an application that does not instrument itself must still boot. +let otel = null; +try { + otel = require('@opentelemetry/api'); +} catch { + // no instrumentation in this application +} + +// The active span is the identity of the request when a SDK is registered: +// minting another id would leave two for the same request. +const activeSpanContext = () => { + const context = otel?.trace.getSpan(otel.context.active())?.spanContext(); + // an all-zero id is what the API returns for an invalid context + return context && !/^0+$/.test(context.traceId) ? context : null; +}; + +const activeTraceId = () => activeSpanContext()?.traceId ?? null; + +// The way back, as W3C Trace Context Level 2 defines it: the trace id, the +// server span id a browser can attach its own span to, and whether the server +// recorded the trace. Without a SDK igo minted the trace id itself, so it mints +// the span id the same way and reports the trace as not recorded. +const traceresponse = (traceId) => { + const span = activeSpanContext(); + const id = span?.spanId ?? randomBytes(8).toString('hex'); + const flags = (span?.traceFlags ?? 0).toString(16).padStart(2, '0'); + return `00-${traceId}-${id}-${flags}`; +}; + +// The version is not pinned to `00`: the spec asks to accept an unknown +// version whose remainder is well formed. +const TRACEPARENT = /^[0-9a-f]{2}-([0-9a-f]{32})-[0-9a-f]{16}-[0-9a-f]{2}$/; + +// An inbound traceparent no SDK will read — an instrumented caller, an igo +// service that is not. Validated: it comes from the client, and can be +// malformed, duplicated (an array, then) or an attempt at injecting into logs. +const traceIdFromHeader = (value) => { + if (typeof value !== 'string') { + return null; + } + const match = TRACEPARENT.exec(value); + if (!match) { + return null; + } + // all zeroes is what the spec calls an invalid id + return /^0+$/.test(match[1]) ? null : match[1]; +}; + +// An error line carries what the call failed with — body, query, params, and +// the response sent — redacted, and truncated: a diagnosis needs the shape of +// an import or an attachment, not its content. A successful line carries none +// of it, which would multiply the volume without teaching anything. +const MAX_LENGTH = 2000; + +const truncate = (value) => { + const text = JSON.stringify(value); + if (!text || text.length <= MAX_LENGTH) { + return value; + } + return `${text.slice(0, MAX_LENGTH)}… (${text.length} chars)`; +}; + +const isEmpty = (value) => + !value || (typeof value === 'object' && Object.keys(value).length === 0); + +const failureContext = (req, res) => { + const context = {}; + if (!isEmpty(req.body)) { + context.body = truncate(redact(req.body)); + } + if (!isEmpty(req.query)) { + context.query = truncate(redact(req.query)); + } + if (!isEmpty(req.params)) { + context.params = redact(req.params); + } + if (res._loggedBody !== undefined) { + context.response = truncate(redact(res._loggedBody)); + } + return context; +}; + +// A response body cannot be read back off `res`: res.json, which every JSON +// answer goes through, keeps it for the error line. +const captureResponseBody = (res) => { + if (typeof res.json !== 'function') { + return; + } + const json = res.json.bind(res); + res.json = (body) => { + if (res.statusCode >= 400) { + res._loggedBody = body; + } + return json(body); + }; +}; + +const levelFor = (status) => { + if (status >= 500) { + return 'error'; + } + return status >= 400 ? 'warn' : 'info'; +}; + +// config.logrequests: a boolean, or a status floor — 400 keeps the errors, +// which are worth every byte, and drops the successes metrics already cover. +const shouldLog = (status) => { + const setting = config.logrequests; + if (setting === false) { + return false; + } + if (typeof setting === 'number') { + return status >= setting; + } + return true; +}; + +logger.provideTraceId(() => storage.getStore()?.traceId); + +// One line per request, carrying the id every log of that request is stamped +// with. Mounted by igo before the routes. +module.exports = (req, res, next) => { + // The active span first: a SDK has already reconciled the inbound header, and + // reversing the two could keep an id diverging from the trace recorded. + const traceId = activeTraceId() + || traceIdFromHeader(req.headers?.traceparent) + || randomBytes(16).toString('hex'); + const start = process.hrtime.bigint(); + + req.traceId = traceId; + + res.setHeader('traceresponse', traceresponse(traceId)); + + captureResponseBody(res); + + storage.run({ traceId }, () => { + // mock responses in tests are plain objects, with no events to listen to + if (typeof res.on === 'function') { + res.on('finish', () => { + if (!shouldLog(res.statusCode)) { + return; + } + const duration = Number(process.hrtime.bigint() - start) / 1e6; + logger.log(levelFor(res.statusCode), 'request', { + method: req.method, + // req.path is rewritten to the router-relative path once mounted + path: (req.originalUrl || req.url || '').split('?')[0], + status: res.statusCode, + duration_ms: Math.round(duration * 10) / 10, + ...(res.statusCode >= 400 ? failureContext(req, res) : {}), + }); + }); + } + next(); + }); +}; + +module.exports.traceId = () => storage.getStore()?.traceId; diff --git a/packages/server/src/connect/security.js b/packages/server/src/connect/security.js new file mode 100644 index 00000000..88a9ce9b --- /dev/null +++ b/packages/server/src/connect/security.js @@ -0,0 +1,43 @@ +const config = require('../config'); +const { isApiRequest } = require('../api/request'); + +// The headers a penetration test asks for, with the values one accepted on an +// igo application in production. No page CSP by default: a working one is made +// of a project's own exceptions (fonts, CDNs, third-party APIs), and a default +// would only be turned off. On an API request the policy is safe to be strict: +// JSON never executes, and a response may carry personal data no intermediate +// cache should keep. +// +// HSTS is only sent in production, over a request that actually arrived in +// HTTPS: a browser ignores it over HTTP anyway, and an intranet served in plain +// HTTP must not be told otherwise. +module.exports = (req, res, next) => { + const security = config.security; + if (!security) { + return next(); + } + + const set = (name, value) => { + if (value) { + res.setHeader(name, value); + } + }; + + set('X-Content-Type-Options', security.noSniff && 'nosniff'); + set('X-Frame-Options', security.frameOptions); + set('Referrer-Policy', security.referrerPolicy); + set('Permissions-Policy', security.permissionsPolicy); + + if (config.env === 'production' && req.secure) { + set('Strict-Transport-Security', security.hsts); + } + + if (isApiRequest(req)) { + set('Content-Security-Policy', security.apiCsp); + set('Cache-Control', security.apiCacheControl); + } else { + set('Content-Security-Policy', security.csp); + } + + next(); +}; diff --git a/packages/server/src/dev/test/agent.js b/packages/server/src/dev/test/agent.js index a075f470..6f3ea77f 100644 --- a/packages/server/src/dev/test/agent.js +++ b/packages/server/src/dev/test/agent.js @@ -73,6 +73,39 @@ const mockResponse = () => { }, 10000); }); + // Express emits 'finish' once a response is written, and middlewares hang + // their after-the-fact work on it — a request log, a metric, an audit trail. + // A mock that never emits it makes all of that untestable. + const listeners = {}; + + res.on = (event, callback) => { + (listeners[event] = listeners[event] || []).push(callback); + return res; + }; + + res.removeListener = (event, callback) => { + listeners[event] = (listeners[event] || []).filter(cb => cb !== callback); + return res; + }; + + res.emit = (event, ...args) => { + for (const callback of listeners[event] || []) { + callback(...args); + } + return (listeners[event] || []).length > 0; + }; + + // Emitted once, like the real thing: a listener added after the response is + // written must not fire it a second time. + let finished = false; + const finish = () => { + if (finished) { + return; + } + finished = true; + res.emit('finish'); + }; + res.getHeader = (name) => { return res.headers[name]; }; @@ -88,6 +121,7 @@ const mockResponse = () => { } res.statusCode = statusCode; res.redirectUrl = redirectUrl; + finish(); resolveResponse(res); }; @@ -101,6 +135,7 @@ const mockResponse = () => { res.send = (data) => { res.body = data; + finish(); resolveResponse(res); }; @@ -108,9 +143,23 @@ const mockResponse = () => { if (chunk) { res.body += chunk; } + finish(); resolveResponse(res); }; + // res.json() serializes through res.send(), so API tests would each have to + // parse res.body themselves. Not named `json`: that would shadow Express's + // own res.json() and break every controller that calls it. + Object.defineProperty(res, 'data', { + get() { + try { + return JSON.parse(res.body); + } catch { + return undefined; + } + } + }); + return { res, done }; }; diff --git a/packages/server/src/logger.js b/packages/server/src/logger.js index d9d584cd..667c7d85 100644 --- a/packages/server/src/logger.js +++ b/packages/server/src/logger.js @@ -3,41 +3,80 @@ const winston = require('winston'); const config = require('./config'); +// Terminal-friendly: one readable line, colours, metadata appended. +const humanFormat = () => winston.format.combine( + winston.format.colorize(), + winston.format.timestamp(), + winston.format.splat(), + winston.format.printf(info => { + const { timestamp, level, message, trace_id, ...rest } = info; + const id = trace_id ? ` [${String(trace_id).slice(0, 8)}]` : ''; + const fields = Object.keys(rest).length ? ` ${JSON.stringify(rest)}` : ''; + return `${timestamp} ${level}:${id} ${message}${fields}`; + }) +); + +// Machine-readable: one JSON object per line, which is what log collectors +// ingest. Colour codes and dropped metadata make text logs unqueryable. +const jsonFormat = () => winston.format.combine( + winston.format.timestamp(), + winston.format.splat(), + winston.format.errors({ stack: true }), + winston.format.json() +); + // const logger = winston.createLogger({ - level: 'info', - format: winston.format.combine( - winston.format.colorize(), - winston.format.timestamp(), - winston.format.splat(), - winston.format.printf(info => { - return `${info.timestamp} ${info.level}: ${info.message}`; - }) - ), - colorize: true, + level: 'info', + format: humanFormat(), transports: [ new winston.transports.Console() ] }); +// Stamps every log emitted during a request with its trace id, so the lines of +// one request can be pulled together — and matched with what the client reports. +// When the OpenTelemetry winston instrumentation is on, it has already set the +// field; igo only fills it when nothing else did. +const withTraceId = winston.format((info) => { + const traceId = module.exports.currentTraceId(); + if (traceId && !info.trace_id) { + info.trace_id = traceId; + } + return info; +}); + // module.exports = logger; +// Provided by the request logger, which owns the per-request storage; kept as +// an injection so logger.js depends on nothing that depends on it. +let currentTraceId = () => undefined; + +module.exports.currentTraceId = () => currentTraceId(); + +module.exports.provideTraceId = (fn) => { + currentTraceId = fn; +}; + // module.exports.init = () => { - // logger.add(new winston.transports.File({ - // filename: `logs/${config.env}.log` - // })); + logger.level = config.loglevel; - logger.level = config.loglevel; + // Once several projects and environments write to the same place, a log line + // is only useful if it says where it comes from. Only in the machine-readable + // format: in a terminal these three are constant and just add noise. + logger.defaultMeta = config.logformat === 'json' ? { + service: config.appname, + version: config.version, + environment: config.env, + } : undefined; - // if (process.env.PAPERTRAIL_HOST && config.env !== 'test') { - // logger.add(new winston.transports.Papertrail({ - // host: 'logs.papertrailapp.com', - // port: 12345 - // })); - // } + logger.format = winston.format.combine( + withTraceId(), + config.logformat === 'json' ? jsonFormat() : humanFormat() + ); logger.debug('Winston logger initialized'); diff --git a/packages/server/src/redact.js b/packages/server/src/redact.js new file mode 100644 index 00000000..c7a016ff --- /dev/null +++ b/packages/server/src/redact.js @@ -0,0 +1,57 @@ + +const config = require('./config'); + +// Keys whose value must never reach a log or a crash email. The default only +// covers what authenticates a caller — the words mean the same in every domain; +// a project's own sensitive fields (an IBAN, a medical record) are its to add +// through config.sensitiveKeys, which replaces this pattern. +// +// The match is anchored: a name *ending* in `password` or `token` in English, +// *starting* with `motDePasse` or `jeton` in French, since the qualifier sits +// on opposite sides in the two languages. `userPassword` and `jetonDeSession` +// are caught; `tokenExpiry`, `cookieJar` and `tokenizer` are not, and neither +// is `tokenApi` — a default that masks a field a diagnosis needed would be a +// nuisance to every project, one that misses a field is one project's to fix. +const ENGLISH = 'password|passwd|token|secret|cookie|authorization'; +const FRENCH = 'mot.?de.?passe|jeton|cle.?secrete'; + +// A trailing `s`, `_confirmation` or `_hash` still names the same thing. +const DEFAULT_SENSITIVE_KEYS = new RegExp( + `(${ENGLISH})(s|_?confirmation|_?confirm|_?hash)?$|(${FRENCH})([A-Z_-]|$)`, + 'i' +); + +const pattern = () => config.sensitiveKeys || DEFAULT_SENSITIVE_KEYS; + +// Only plain objects and arrays are walked. A Date or a Buffer copied key by +// key comes out as `{}`, which is worse than the value it replaced. +const isPlain = (value) => { + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +}; + +// Returns a copy with every sensitive value replaced. Circular references are +// tracked: a request body can hold one, and a crash report must not recurse +// until the stack gives out. +const redact = (value, seen = new WeakSet()) => { + if (!value || typeof value !== 'object') { + return value; + } + if (!Array.isArray(value) && !isPlain(value)) { + return value; + } + if (seen.has(value)) { + return '[circular]'; + } + seen.add(value); + + const keys = pattern(); + const copy = Array.isArray(value) ? [] : {}; + for (const key in value) { + copy[key] = keys.test(key) ? '[redacted]' : redact(value[key], seen); + } + return copy; +}; + +module.exports = redact; +module.exports.DEFAULT_SENSITIVE_KEYS = DEFAULT_SENSITIVE_KEYS; diff --git a/packages/server/src/routes.js b/packages/server/src/routes.js index a136a757..2930e10f 100644 --- a/packages/server/src/routes.js +++ b/packages/server/src/routes.js @@ -1,13 +1,25 @@ -const config = require('./config'); +const config = require('./config'); +const api = require('./api'); +const problem = require('./api/problem'); +const { isApiRequest } = require('./api/request'); const routes = require(config.projectRoot + '/app/routes'); // module.exports.init = function(app) { + // order matters: init() adds app.api(), which the project calls in its own + // routes, and wire() applies the schemas of the handlers it just declared. + api.init(app); + routes.init(app); + api.wire(); + // 404 app.all(/.*/, (req, res) => { + if (isApiRequest(req)) { + return problem.send(res, 404); + } res.status(404).render('errors/404'); }); }; diff --git a/packages/server/test/ApiTest.js b/packages/server/test/ApiTest.js new file mode 100644 index 00000000..aadd85ce --- /dev/null +++ b/packages/server/test/ApiTest.js @@ -0,0 +1,90 @@ +require('./init'); + +const assert = require('assert'); +const agent = require('@igojs/server').dev.agent; + +describe('API', function() { + + describe('validation', function() { + + it('should pass a valid body through', async () => { + const res = await agent.post('/api/books', { body: { title: 'Dune', pages: 412 } }); + assert.strictEqual(res.statusCode, 201); + assert.deepStrictEqual(res.data, { id: 1, title: 'Dune', pages: 412 }); + }); + + it('should reject an invalid body with a problem document', async () => { + const res = await agent.post('/api/books', { body: { title: 'Dune', pages: 'many' } }); + assert.strictEqual(res.statusCode, 400); + assert.strictEqual(res.data.status, 400); + assert.strictEqual(res.data.type, 'urn:igo:validation-failed'); + assert.strictEqual(res.data.title, 'Validation failed'); + assert.deepStrictEqual(res.data.errors, [{ + path: 'pages', + code: 'invalid_type', + message: 'Invalid input: expected number, received string', + }]); + }); + + it('should report every invalid field', async () => { + const res = await agent.post('/api/books', { body: {} }); + assert.strictEqual(res.statusCode, 400); + assert.deepStrictEqual(res.data.errors.map(e => e.path).sort(), ['pages', 'title']); + }); + + it('should coerce query params to their schema type', async () => { + const res = await agent.get('/api/books?page=3'); + assert.strictEqual(res.statusCode, 200); + assert.strictEqual(res.data.page, 3); + assert.strictEqual(res.data.typeofPage, 'number'); + }); + + it('should apply query defaults when the param is absent', async () => { + const res = await agent.get('/api/books'); + assert.strictEqual(res.data.page, 1); + }); + + it('should reject an invalid query param', async () => { + const res = await agent.get('/api/books?status=burned'); + assert.strictEqual(res.statusCode, 400); + assert.deepStrictEqual(res.data.errors.map(e => e.path), ['status']); + }); + + it('should leave a route without schema untouched', async () => { + const res = await agent.post('/api/books/bulk', { body: { anything: true } }); + assert.strictEqual(res.statusCode, 200); + assert.deepStrictEqual(res.data, { ok: true }); + }); + }); + + describe('errors', function() { + + it('should answer 404 in JSON under the api prefix', async () => { + const res = await agent.get('/api/nope'); + assert.strictEqual(res.statusCode, 404); + assert.strictEqual(res.data.status, 404); + assert.strictEqual(res.data.title, 'Not Found'); + }); + + it('should still render HTML for a non-api 404', async () => { + const res = await agent.get('/nope'); + assert.strictEqual(res.statusCode, 404); + assert.strictEqual(res.data, undefined); + }); + + it('should answer JSON when the client asks for it', async () => { + const res = await agent.get('/nope', { headers: { accept: 'application/json' } }); + assert.strictEqual(res.statusCode, 404); + assert.strictEqual(res.data.status, 404); + }); + }); + + describe('routing', function() { + + it('should mount the router under the api prefix', async () => { + const res = await agent.get('/api/books/7'); + assert.strictEqual(res.statusCode, 200); + assert.strictEqual(res.data.id, 7); + }); + }); +}); diff --git a/packages/server/test/ConfigTest.js b/packages/server/test/ConfigTest.js index fa2ba6a5..d5b78a63 100644 --- a/packages/server/test/ConfigTest.js +++ b/packages/server/test/ConfigTest.js @@ -1,10 +1,50 @@ require('./init'); const assert = require('assert'); +const path = require('path'); const config = require('@igojs/server').config; describe('igo.config', () => { + describe('app identity', () => { + + it('should name the app after the project package, for crash emails and logs', () => { + const projectPackage = require('./project/package.json'); + assert.strictEqual(config.appname, projectPackage.name); + assert.strictEqual(config.version, projectPackage.version); + }); + + // `serve` scripts run from dist/, which has no package.json of its own + it('should climb to the nearest package.json when projectRoot is a build directory', () => { + const found = config.readProjectPackage(path.join(__dirname, 'project', 'app')); + assert.strictEqual(found.name, require('./project/package.json').name); + }); + + it('should boot a project with no package.json at all', () => { + assert.deepStrictEqual(config.readProjectPackage(path.parse(__dirname).root), {}); + }); + }); + + // init() runs once per process, so the parser is tested on its own + describe('LOG_REQUESTS', () => { + const parse = config.parseLogRequests; + + it('should read a status floor', () => { + assert.strictEqual(parse('400', true), 400); + }); + + it('should read true and false', () => { + assert.strictEqual(parse('true', false), true); + assert.strictEqual(parse('false', true), false); + }); + + it('should keep the default when unset or not understood', () => { + assert.strictEqual(parse(undefined, true), true); + assert.strictEqual(parse('loud', false), false); + assert.strictEqual(parse('0', true), true); + }); + }); + describe('config.checkSecrets', () => { const withConfig = (overrides, fn) => { diff --git a/packages/server/test/CreateTest.js b/packages/server/test/CreateTest.js new file mode 100644 index 00000000..0dc85f49 --- /dev/null +++ b/packages/server/test/CreateTest.js @@ -0,0 +1,85 @@ +require('./init'); + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const create = require('@igojs/server/cli/create'); + +const SKELETONS = ['tailwind', 'fullstack']; + +describe('cli/create', function() { + this.timeout(20000); + + let cwd, tmp; + + beforeEach(() => { + cwd = process.cwd(); + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'igo-create-')); + process.chdir(tmp); + }); + + afterEach(() => { + process.chdir(cwd); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + SKELETONS.forEach((skel) => { + it(`should create a project from the ${skel} skeleton`, async () => { + await create({ _: ['create', 'myapp'], skel }); + + const pkg = JSON.parse(fs.readFileSync(path.join(tmp, 'myapp', 'package.json'), 'utf8')); + assert(pkg.name.startsWith('myapp'), `project name was not substituted: ${pkg.name}`); + assert(!/\{[a-z.]+\}/.test(JSON.stringify(pkg)), 'a placeholder was left unreplaced'); + + // _.gitignore is renamed on the way out + assert(fs.existsSync(path.join(tmp, 'myapp', '.gitignore'))); + }); + }); + + it('should default to the tailwind skeleton', async () => { + await create({ _: ['create', 'myapp'] }); + assert(fs.existsSync(path.join(tmp, 'myapp', 'views')), 'tailwind skeleton has views'); + }); + + it('should carry the api conventions into the api skeleton', async () => { + await create({ _: ['create', 'myapi'], skel: 'fullstack' }); + + const routes = fs.readFileSync(path.join(tmp, 'myapi', 'api', 'app', 'routes.ts'), 'utf8'); + assert(routes.includes('app.api('), 'routes mount through app.api()'); + + const controller = fs.readFileSync( + path.join(tmp, 'myapi', 'api', 'app', 'features', 'books', 'books.controller.ts'), 'utf8'); + assert(controller.includes('create.body = dto.CreateBook'), + 'schema is attached to the handler'); + assert(controller.includes('\'/problems/book-not-found\''), + 'business errors carry their own problem type'); + }); + + it('should draw the session secrets into the .env, never into the code', async () => { + await create({ _: ['create', 'myapi'], skel: 'fullstack' }); + + const env = fs.readFileSync(path.join(tmp, 'myapi', 'api', '.env'), 'utf8'); + assert.match(env, /^COOKIE_SECRET=[A-Za-z0-9]{40}$/m); + assert.match(env, /^COOKIE_SESSION_KEYS=[A-Za-z0-9]{40}$/m); + + const app = fs.readdirSync(path.join(tmp, 'myapi', 'api', 'app'), { recursive: true }) + .filter(f => f.endsWith('.ts')) + .map(f => fs.readFileSync(path.join(tmp, 'myapi', 'api', 'app', f), 'utf8')); + assert(!app.some(source => source.includes('cookieSecret')), + 'a generated secret would be committed with the code'); + }); + + it('should ship migrations and seeds in the api skeleton', async () => { + await create({ _: ['create', 'myapi'], skel: 'fullstack' }); + + const pkg = JSON.parse( + fs.readFileSync(path.join(tmp, 'myapi', 'api', 'package.json'), 'utf8')); + assert(pkg.scripts.migrate, 'migrate script'); + // the seeds import the TS models, so the CLI needs the tsx loader + assert(pkg.scripts.seed.includes('tsx'), `seed runs under tsx: ${pkg.scripts.seed}`); + + assert(fs.existsSync(path.join(tmp, 'myapi', 'api', 'seeds', '001-books.ts'))); + }); +}); diff --git a/packages/server/test/HealthTest.js b/packages/server/test/HealthTest.js new file mode 100644 index 00000000..32da358b --- /dev/null +++ b/packages/server/test/HealthTest.js @@ -0,0 +1,169 @@ +require('./init'); + +const assert = require('assert'); +const cache = require('../src/cache'); +const config = require('../src/config'); +const db = require('@igojs/db'); +const agent = require('@igojs/server').dev.agent; + +describe('Health', function() { + + describe('liveness', function() { + + it('should answer without touching a dependency', async () => { + const res = await agent.get('/health'); + assert.strictEqual(res.statusCode, 200); + assert.deepStrictEqual(res.data, { status: 'UP' }); + }); + }); + + describe('readiness', function() { + + it('should report every probed dependency', async () => { + const res = await agent.get('/health/ready'); + assert.strictEqual(res.statusCode, 200); + assert.deepStrictEqual(res.data, { + status: 'UP', + components: { + db: { status: 'UP' }, + cache: { status: 'UP' }, + disk: { status: 'UP' }, + }, + }); + }); + + it('should answer 503 when a dependency is down', async () => { + const query = db.dbs.main.query; + db.dbs.main.query = async () => { + throw new Error('connection refused to 10.0.0.5:3306'); + }; + + try { + const res = await agent.get('/health/ready'); + assert.strictEqual(res.statusCode, 503); + assert.strictEqual(res.data.status, 'DOWN'); + assert.deepStrictEqual(res.data.components.db, { status: 'DOWN' }); + assert.deepStrictEqual(res.data.components.cache, { status: 'UP' }); + } finally { + db.dbs.main.query = query; + } + }); + + it('should expose no error detail, which names hosts and ports', async () => { + const query = db.dbs.main.query; + db.dbs.main.query = async () => { + throw new Error('connection refused to 10.0.0.5:3306'); + }; + + try { + const res = await agent.get('/health/ready'); + assert.ok(!JSON.stringify(res.data).includes('10.0.0.5')); + } finally { + db.dbs.main.query = query; + } + }); + + it('should stay ready when an optional dependency is down', async () => { + const isAvailable = cache.isAvailable; + cache.isAvailable = () => false; + + try { + const res = await agent.get('/health/ready'); + assert.strictEqual(res.statusCode, 200); + assert.strictEqual(res.data.status, 'UP'); + assert.deepStrictEqual(res.data.components.cache, { status: 'DOWN' }); + } finally { + cache.isAvailable = isAvailable; + } + }); + + it('should answer 503 when that same dependency is declared critical', async () => { + const isAvailable = cache.isAvailable; + const setting = config.health.cache; + cache.isAvailable = () => false; + config.health.cache = true; + + try { + const res = await agent.get('/health/ready'); + assert.strictEqual(res.statusCode, 503); + assert.strictEqual(res.data.status, 'DOWN'); + } finally { + cache.isAvailable = isAvailable; + config.health.cache = setting; + } + }); + + it('should answer 503 rather than hang on a dependency that never answers', async () => { + const query = db.dbs.main.query; + const timeout = config.health.timeout; + config.health.timeout = 50; + db.dbs.main.query = () => new Promise(() => {}); + + try { + const res = await agent.get('/health/ready'); + assert.strictEqual(res.statusCode, 503); + assert.deepStrictEqual(res.data.components.db, { status: 'DOWN' }); + } finally { + db.dbs.main.query = query; + config.health.timeout = timeout; + } + }); + + it('should probe only what the config asks for', async () => { + config.health.cache = false; + config.health.disk = false; + + try { + const res = await agent.get('/health/ready'); + assert.deepStrictEqual(res.data.components, { db: { status: 'UP' } }); + } finally { + config.health.cache = true; + config.health.disk = 50 * 1024 * 1024; + } + }); + + it('should report the disk down below the free space threshold', async () => { + const threshold = config.health.disk; + // more than any disk holds, so the probe cannot pass + config.health.disk = Number.MAX_SAFE_INTEGER; + + try { + const res = await agent.get('/health/ready'); + assert.strictEqual(res.statusCode, 503); + assert.deepStrictEqual(res.data.components.disk, { status: 'DOWN' }); + } finally { + config.health.disk = threshold; + } + }); + + it('should expose neither the free space nor the threshold', async () => { + const threshold = config.health.disk; + config.health.disk = Number.MAX_SAFE_INTEGER; + + try { + const res = await agent.get('/health/ready'); + assert.deepStrictEqual(Object.keys(res.data.components.disk), ['status']); + } finally { + config.health.disk = threshold; + } + }); + }); + + describe('request log', function() { + + it('should not log the probes', async () => { + const logger = require('../src/logger'); + const log = logger.log; + const lines = []; + logger.log = (level, message, meta) => lines.push({ level, message, meta }); + + try { + await agent.get('/health'); + await agent.get('/health/ready'); + assert.deepStrictEqual(lines.filter(l => l.message === 'request'), []); + } finally { + logger.log = log; + } + }); + }); +}); diff --git a/packages/server/test/LoggerTest.js b/packages/server/test/LoggerTest.js new file mode 100644 index 00000000..7e875850 --- /dev/null +++ b/packages/server/test/LoggerTest.js @@ -0,0 +1,116 @@ +require('./init'); + +const assert = require('assert'); +const winston = require('winston'); +const { Writable } = require('stream'); + +const config = require('@igojs/server').config; +const logger = require('@igojs/server').logger; + +// Captures what a transport would actually write, which is the only way to +// tell a readable line from an ingestible JSON object. +const captureOutput = (fn, reconfigure) => { + const lines = []; + const format = logger.format; + const level = logger.level; + const transports = logger.transports.slice(); + + logger.clear(); + logger.add(new winston.transports.Stream({ + stream: new Writable({ + write(chunk, encoding, callback) { + lines.push(chunk.toString().trim()); + callback(); + }, + }), + })); + if (reconfigure) { + reconfigure(); + lines.length = 0; + } + logger.level = 'info'; + + try { + fn(); + } finally { + logger.clear(); + transports.forEach(t => logger.add(t)); + logger.format = format; + logger.level = level; + } + return lines; +}; + +// logger.init() emits a line of its own: run it while the transports are +// already swapped out, so it neither pollutes the console nor the capture. +const withFormat = (logformat, fn) => { + const initial = config.logformat; + config.logformat = logformat; + try { + return captureOutput(fn, () => logger.init()); + } finally { + config.logformat = initial; + captureOutput(() => {}, () => logger.init()); + } +}; + +describe('Logger', function() { + + describe('json format', function() { + + it('should emit one parseable object per line', () => { + const [line] = withFormat('json', () => logger.info('hello')); + const entry = JSON.parse(line); + assert.strictEqual(entry.message, 'hello'); + assert.strictEqual(entry.level, 'info'); + assert(entry.timestamp); + }); + + it('should keep metadata as fields, not drop them', () => { + const [line] = withFormat('json', () => logger.info('done', { user_id: 42, folder_id: 7 })); + const entry = JSON.parse(line); + assert.strictEqual(entry.user_id, 42); + assert.strictEqual(entry.folder_id, 7); + }); + + it('should carry the stack of an error', () => { + const [line] = withFormat('json', () => logger.error(new Error('boom'))); + const entry = JSON.parse(line); + assert.strictEqual(entry.message, 'boom'); + assert(entry.stack.includes('Error: boom')); + }); + + it('should say which service, version and environment a line comes from', () => { + const [line] = withFormat('json', () => logger.info('hello')); + const entry = JSON.parse(line); + assert.strictEqual(entry.environment, config.env); + assert.strictEqual(entry.service, config.appname); + assert.strictEqual(entry.version, config.version); + }); + + it('should not colour what a log collector reads', () => { + const [line] = withFormat('json', () => logger.info('plain')); + // eslint-disable-next-line no-control-regex + assert(!/\[/.test(line), 'ANSI escape codes leaked into the JSON output'); + }); + }); + + describe('human format', function() { + + it('should stay on one readable line', () => { + const [line] = withFormat('human', () => logger.info('hello')); + assert(line.includes('hello')); + assert.throws(() => JSON.parse(line)); + }); + + it('should append metadata rather than lose it', () => { + const [line] = withFormat('human', () => logger.info('done', { user_id: 42 })); + assert(line.includes('"user_id":42')); + }); + + it('should leave out the fields that are constant in a terminal', () => { + const [line] = withFormat('human', () => logger.info('done')); + assert(!line.includes('environment'), 'service/version/environment are json-only'); + }); + }); +}); diff --git a/packages/server/test/RedactTest.js b/packages/server/test/RedactTest.js new file mode 100644 index 00000000..5ab2fb2f --- /dev/null +++ b/packages/server/test/RedactTest.js @@ -0,0 +1,98 @@ +require('./init'); + +const assert = require('assert'); + +const { config, redact } = require('@igojs/server'); + +describe('redact', function() { + + afterEach(function() { + config.sensitiveKeys = null; + }); + + it('should leave a Date or a Buffer as it is', () => { + const when = new Date('2026-01-01'); + const out = redact({ when, raw: Buffer.from('ab'), nested: { password: 'x' } }); + assert.strictEqual(out.when, when); + assert(Buffer.isBuffer(out.raw)); + assert.strictEqual(out.nested.password, '[redacted]'); + }); + + it('should redact the usual English field names', () => { + const out = redact({ password: 'x', token: 'y', authorization: 'z', cookie: 'c' }); + assert.deepStrictEqual(out, { + password: '[redacted]', + token: '[redacted]', + authorization: '[redacted]', + cookie: '[redacted]', + }); + }); + + // The language convention makes French field names the expected case, so a + // pattern that only knows `password` would leak the most common one of all. + it('should redact the French field names', () => { + const out = redact({ motDePasse: 'x', mot_de_passe: 'y', jeton: 'z' }); + assert.deepStrictEqual(out, { + motDePasse: '[redacted]', + mot_de_passe: '[redacted]', + jeton: '[redacted]', + }); + }); + + // Personal, but also what identifies the request that failed. Left in on + // purpose: a log holding an email is a retention question, not a leaked + // credential. + it('should leave harmless fields untouched', () => { + const out = redact({ email: 'a@b.c', nom: 'Alice', pages: 412, phone: '0600' }); + assert.deepStrictEqual(out, { email: 'a@b.c', nom: 'Alice', pages: 412, phone: '0600' }); + }); + + it('should reach into nested objects and arrays', () => { + const out = redact({ user: { motDePasse: 'x' }, list: [{ token: 't' }] }); + assert.deepStrictEqual(out, { user: { motDePasse: '[redacted]' }, list: [{ token: '[redacted]' }] }); + }); + + // A request body can hold a circular reference, and a crash report must not + // recurse until the stack gives out. + it('should survive a circular reference', () => { + const loop = { nom: 'a' }; + loop.self = loop; + assert.deepStrictEqual(redact(loop), { nom: 'a', self: '[circular]' }); + }); + + it('should leave primitives as they are', () => { + assert.strictEqual(redact('hello'), 'hello'); + assert.strictEqual(redact(42), 42); + assert.strictEqual(redact(null), null); + assert.strictEqual(redact(undefined), undefined); + }); + + // The default covers what authenticates a caller, and stops there: a domain + // field is the project's to declare, since igo cannot guess it. + it('should leave domain fields to the project', () => { + const out = redact({ iban: 'FR76', creditCard: '4111', ssn: '185' }); + assert.deepStrictEqual(out, { iban: 'FR76', creditCard: '4111', ssn: '185' }); + }); + + it('should catch a credential whatever it is prefixed with', () => { + const out = redact({ userPassword: 'x', accessToken: 'x', clientSecret: 'x' }); + for (const [key, value] of Object.entries(out)) { + assert.strictEqual(value, '[redacted]', `${key} left in the clear`); + } + }); + + it('should let a project set its own pattern', () => { + config.sensitiveKeys = /dossierMedical/i; + assert.deepStrictEqual( + redact({ dossierMedical: 'x', nom: 'Alice' }), + { dossierMedical: '[redacted]', nom: 'Alice' }); + }); + + it('should let a project extend the defaults', () => { + config.sensitiveKeys = new RegExp(`${redact.DEFAULT_SENSITIVE_KEYS.source}|iban`, 'i'); + assert.deepStrictEqual( + redact({ iban: 'FR76', motDePasse: 'y', nom: 'Alice' }), + { iban: '[redacted]', motDePasse: '[redacted]', nom: 'Alice' }); + }); + +}); diff --git a/packages/server/test/RequestLoggerTest.js b/packages/server/test/RequestLoggerTest.js new file mode 100644 index 00000000..8f3338a5 --- /dev/null +++ b/packages/server/test/RequestLoggerTest.js @@ -0,0 +1,146 @@ +require('./init'); + +const assert = require('assert'); + +const { config, logger } = require('@igojs/server'); + +const middleware = require('../src/connect/requestlogger'); + +// Drives the middleware with a fake response, and returns what got logged. +const run = (status, extra = {}) => { + const lines = []; + const log = logger.log; + logger.log = (level, message, meta) => lines.push({ level, message, meta }); + + let finish; + const req = { method: 'GET', originalUrl: '/api/books', headers: {}, ...extra }; + const res = { + statusCode: status, + setHeader: () => {}, + on: (_e, cb) => { finish = cb; }, + json: (body) => { res.sent = body; return res; }, + }; + + try { + middleware(req, res, () => {}); + finish(); + } finally { + logger.log = log; + } + return lines; +}; + +describe('request logger', function() { + + afterEach(function() { + config.logrequests = config.env !== 'test'; + }); + + it('should log every request when true', () => { + config.logrequests = true; + assert.strictEqual(run(200).length, 1); + assert.strictEqual(run(500).length, 1); + }); + + it('should log nothing when false', () => { + config.logrequests = false; + assert.strictEqual(run(200).length, 0); + assert.strictEqual(run(500).length, 0); + }); + + // The point of the floor: successes are the volume, errors are the signal. + it('should keep only the errors above a status floor', () => { + config.logrequests = 400; + assert.strictEqual(run(200).length, 0); + assert.strictEqual(run(304).length, 0); + assert.strictEqual(run(400).length, 1); + assert.strictEqual(run(500).length, 1); + }); + + it('should pick the level from the status', () => { + config.logrequests = true; + assert.strictEqual(run(200)[0].level, 'info'); + assert.strictEqual(run(404)[0].level, 'warn'); + assert.strictEqual(run(500)[0].level, 'error'); + }); + + // A 400 without its body is diagnosed by guesswork, and a 500 rarely + // reproduces on demand. + it('should carry the request context when the response is an error', () => { + config.logrequests = true; + const { meta } = run(500, { body: { title: 'Dune' }, query: { page: '2' } })[0]; + assert.deepStrictEqual(meta.body, { title: 'Dune' }); + assert.deepStrictEqual(meta.query, { page: '2' }); + }); + + it('should carry no context on a successful response', () => { + config.logrequests = true; + const { meta } = run(200, { body: { title: 'Dune' } })[0]; + assert.strictEqual(meta.body, undefined); + }); + + // The point of going through redact(): a failed sign-in must not drop a + // password into the logs, where it would be kept and searchable. + it('should redact sensitive fields of the body', () => { + config.logrequests = true; + const { meta } = run(401, { + body: { email: 'a@b.c', motDePasse: 'sup3rS3cret', password: 'other' }, + })[0]; + assert.strictEqual(meta.body.motDePasse, '[redacted]'); + assert.strictEqual(meta.body.password, '[redacted]'); + assert.strictEqual(meta.body.email, 'a@b.c'); + }); + + it('should truncate an oversized body', () => { + config.logrequests = true; + const { meta } = run(400, { body: { blob: 'x'.repeat(5000) } })[0]; + assert.strictEqual(typeof meta.body, 'string'); + assert.match(meta.body, /chars\)$/); + }); + + // The response is what the client was actually answered: without it, a + // problem document has to be inferred from the status alone. + it('should carry the response body of an error', () => { + config.logrequests = true; + const lines = []; + const log = logger.log; + logger.log = (level, message, meta) => lines.push({ level, message, meta }); + + let finish; + const req = { method: 'POST', originalUrl: '/api/books', headers: {} }; + const res = { + statusCode: 200, + setHeader: () => {}, + on: (_e, cb) => { finish = cb; }, + json: (body) => { res.sent = body; return res; }, + }; + + try { + middleware(req, res, () => {}); + res.statusCode = 422; + res.json({ type: 'urn:igo:validation-failed', status: 422 }); + finish(); + } finally { + logger.log = log; + } + + assert.deepStrictEqual(lines[0].meta.response, + { type: 'urn:igo:validation-failed', status: 422 }); + }); + + it('should carry no response body on success', () => { + config.logrequests = true; + const { meta } = run(200)[0]; + assert.strictEqual(meta.response, undefined); + }); + + it('should carry method, path, status and duration', () => { + config.logrequests = true; + const { meta } = run(200)[0]; + assert.strictEqual(meta.method, 'GET'); + assert.strictEqual(meta.path, '/api/books'); + assert.strictEqual(meta.status, 200); + assert.strictEqual(typeof meta.duration_ms, 'number'); + }); + +}); diff --git a/packages/server/test/SecurityHeadersTest.js b/packages/server/test/SecurityHeadersTest.js new file mode 100644 index 00000000..8b448703 --- /dev/null +++ b/packages/server/test/SecurityHeadersTest.js @@ -0,0 +1,77 @@ +require('./init'); + +const assert = require('assert'); +const { config } = require('@igojs/server'); +const agent = require('@igojs/server').dev.agent; +const security = require('@igojs/server/src/connect/security'); + +const withSecurity = async (overrides, fn) => { + const saved = config.security; + config.security = overrides === false ? false : { ...saved, ...overrides }; + try { + await fn(); + } finally { + config.security = saved; + } +}; + +describe('security headers', function() { + + it('should send the page headers a pentest asks for', async () => { + const res = await agent.get('/'); + assert.strictEqual(res.headers['X-Content-Type-Options'], 'nosniff'); + assert.strictEqual(res.headers['X-Frame-Options'], 'SAMEORIGIN'); + assert.strictEqual(res.headers['Referrer-Policy'], 'strict-origin-when-cross-origin'); + assert.strictEqual(res.headers['Permissions-Policy'], 'camera=(), microphone=(), geolocation=()'); + }); + + // a working CSP is made of a project's own exceptions: none is invented here + it('should send no page CSP unless the project declares one', async () => { + assert.strictEqual((await agent.get('/')).headers['Content-Security-Policy'], undefined); + await withSecurity({ csp: 'default-src \'self\'' }, async () => { + assert.strictEqual((await agent.get('/')).headers['Content-Security-Policy'], 'default-src \'self\''); + }); + }); + + it('should lock down API responses, which never execute and may carry personal data', async () => { + const res = await agent.get('/api/books'); + assert.strictEqual(res.headers['Content-Security-Policy'], 'default-src \'none\'; frame-ancestors \'none\''); + assert.strictEqual(res.headers['Cache-Control'], 'no-store'); + assert.strictEqual(res.headers['X-Content-Type-Options'], 'nosniff'); + }); + + it('should drop a header set to false, and all of them when security is false', async () => { + await withSecurity({ frameOptions: false }, async () => { + const res = await agent.get('/'); + assert.strictEqual(res.headers['X-Frame-Options'], undefined); + assert.strictEqual(res.headers['X-Content-Type-Options'], 'nosniff'); + }); + await withSecurity(false, async () => { + assert.strictEqual((await agent.get('/')).headers['X-Content-Type-Options'], undefined); + }); + }); + + describe('HSTS', function() { + const run = (env, secure) => { + const sent = {}; + const saved = config.env; + config.env = env; + try { + security({ path: '/', headers: {}, secure }, { setHeader: (k, v) => { sent[k] = v; } }, () => {}); + } finally { + config.env = saved; + } + return sent['Strict-Transport-Security']; + }; + + it('should be sent in production over HTTPS', () => { + assert.strictEqual(run('production', true), 'max-age=63072000; includeSubDomains'); + }); + + // a browser ignores it over HTTP, and an intranet in plain HTTP must not be told otherwise + it('should not be sent over HTTP, nor outside production', () => { + assert.strictEqual(run('production', false), undefined); + assert.strictEqual(run('dev', true), undefined); + }); + }); +}); diff --git a/packages/server/test/TraceContextTest.js b/packages/server/test/TraceContextTest.js new file mode 100644 index 00000000..fe80ed93 --- /dev/null +++ b/packages/server/test/TraceContextTest.js @@ -0,0 +1,188 @@ +require('./init'); + +const assert = require('assert'); +const otel = require('@opentelemetry/api'); +const winston = require('winston'); +const { Writable } = require('stream'); + +const { config, logger } = require('@igojs/server'); +const middleware = require('../src/connect/requestlogger'); + +// Drives the middleware and returns what it settled on. +const run = (headers = {}, next = () => {}) => { + const sent = {}; + const req = { method: 'GET', originalUrl: '/', headers }; + const res = { + statusCode: 200, + setHeader: (name, value) => { sent[name] = value; }, + on: () => {}, + }; + middleware(req, res, next); + return { traceId: req.traceId, sent }; +}; + +// The OpenTelemetry API ships without a context manager: its `with()` runs the +// callback but `active()` keeps answering the root context. This one is just +// enough to make a span active for the duration of a callback. +class StackContextManager { + constructor() { this.stack = [otel.ROOT_CONTEXT]; } + active() { return this.stack[this.stack.length - 1]; } + with(context, fn, thisArg, ...args) { + this.stack.push(context); + try { + return fn.call(thisArg, ...args); + } finally { + this.stack.pop(); + } + } + bind(context, target) { return target; } + enable() { return this; } + disable() { return this; } +} + +const SPAN = { traceId: '0af7651916cd43dd8448eb211c80319c', spanId: 'b7ad6b7169203331', traceFlags: 1 }; + +// Runs fn with a span carrying SPAN active, the way a registered SDK would. +const withActiveSpan = (fn) => { + otel.context.setGlobalContextManager(new StackContextManager()); + try { + const span = otel.trace.wrapSpanContext(SPAN); + return otel.context.with(otel.trace.setSpan(otel.context.active(), span), fn); + } finally { + otel.context.disable(); + } +}; + +// Captures the JSON lines the logger writes while fn runs. +const captureLogs = (fn) => { + const lines = []; + const transports = logger.transports.slice(); + const saved = { format: logger.format, level: logger.level, logformat: config.logformat }; + + logger.clear(); + logger.add(new winston.transports.Stream({ + stream: new Writable({ + write(chunk, encoding, callback) { lines.push(JSON.parse(chunk.toString())); callback(); }, + }), + })); + config.logformat = 'json'; + logger.init(); + lines.length = 0; + logger.level = 'info'; + try { + fn(); + } finally { + logger.clear(); + transports.forEach(t => logger.add(t)); + config.logformat = saved.logformat; + logger.format = saved.format; + logger.level = saved.level; + } + return lines; +}; + +const TRACE_ID = /^[0-9a-f]{32}$/; + +describe('trace context', function() { + + // Without a registered SDK there is no active span, so igo produces a value + // of its own — with the shape of a trace id, so that the day instrumentation + // arrives it is replaced by a real one with no code change. + it('should generate a trace-id-shaped value when nothing provides one', () => { + assert.match(run().traceId, TRACE_ID); + }); + + // The case that gets forgotten: an instrumented service calling an igo + // service that is not. Its traceparent must not be discarded. + it('should adopt the trace id of an inbound traceparent', () => { + const { traceId } = run({ + traceparent: '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', + }); + assert.strictEqual(traceId, '4bf92f3577b34da6a3ce929d0e0e4736'); + }); + + it('should adopt it even when the caller did not sample', () => { + const { traceId } = run({ + traceparent: '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-00', + }); + assert.strictEqual(traceId, '4bf92f3577b34da6a3ce929d0e0e4736'); + }); + + // Trace Context Level 2 exists: refusing an unknown version would lose + // correlation the day a caller moves up. + it('should accept an unknown version whose remainder is well formed', () => { + const { traceId } = run({ + traceparent: '01-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', + }); + assert.strictEqual(traceId, '4bf92f3577b34da6a3ce929d0e0e4736'); + }); + + // The header comes from the client, so it is validated: it can be malformed, + // duplicated, or carry an attempt at injecting into the logs. + it('should reject a malformed traceparent and fall back', () => { + const refuses = [ + '00-00000000000000000000000000000000-00f067aa0ba902b7-01', // all zeroes + '00-4bf92f3577b34da6-00f067aa0ba902b7-01', // too short + '00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01', // uppercase + '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7', // truncated + 'garbage', + '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01\n{"level":"info"}', + ]; + for (const traceparent of refuses) { + const { traceId } = run({ traceparent }); + assert.match(traceId, TRACE_ID, `rejected: ${traceparent}`); + assert.notStrictEqual(traceId, '4bf92f3577b34da6a3ce929d0e0e4736'); + } + }); + + // A duplicated header reaches express as an array. + it('should reject a duplicated header', () => { + const { traceId } = run({ + traceparent: [ + '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', + '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-00f067aa0ba902b7-01', + ], + }); + assert.match(traceId, TRACE_ID); + assert.notStrictEqual(traceId, '4bf92f3577b34da6a3ce929d0e0e4736'); + }); + + // A registered SDK has already reconciled the inbound header into the active + // span: that span is the identity, even when the header says otherwise. + it('should adopt the trace id of the active span over the inbound header', () => { + const { traceId } = withActiveSpan(() => run({ + traceparent: '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', + })); + assert.strictEqual(traceId, SPAN.traceId); + }); + + // traceresponse is the way back defined by Trace Context Level 2: it carries + // the server span id, which is what lets a browser attach its span to it. + it('should send traceresponse with the active span when instrumented', () => { + const { sent } = withActiveSpan(() => run()); + assert.strictEqual(sent.traceresponse, `00-${SPAN.traceId}-${SPAN.spanId}-01`); + }); + + it('should stamp every log emitted during the request with its trace id', () => { + let traceId; + const lines = captureLogs(() => { + ({ traceId } = run({}, () => logger.info('inside the request', { step: 1 }))); + }); + assert.strictEqual(lines.length, 1); + assert.strictEqual(lines[0].message, 'inside the request'); + assert.strictEqual(lines[0].trace_id, traceId); + }); + + it('should expose no id outside of a request', () => { + assert.strictEqual(middleware.traceId(), undefined); + }); + + // igo minted the trace id, so it mints the span id too, and says the trace + // was not recorded: the client still gets the id support will look for. + it('should send traceresponse with a generated span id when not instrumented', () => { + const { traceId, sent } = run(); + assert.strictEqual(sent.traceresponse, `00-${traceId}-${sent.traceresponse.slice(36, 52)}-00`); + assert.match(sent.traceresponse.slice(36, 52), /^[0-9a-f]{16}$/); + }); + +}); diff --git a/packages/server/test/UncaughtExceptionTest.js b/packages/server/test/UncaughtExceptionTest.js new file mode 100644 index 00000000..749a2919 --- /dev/null +++ b/packages/server/test/UncaughtExceptionTest.js @@ -0,0 +1,36 @@ +require('./init'); + +const assert = require('assert'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +// .cjs, outside the test glob: mocha would otherwise load it as a test +// file and the script would exit the runner itself. +const SCRIPT = path.join(__dirname, 'fixtures', 'uncaught.cjs'); + +// process.exit() cannot be observed from inside the test process: run each +// scenario in a child and read its exit code. +const run = (mode) => { + try { + execFileSync(process.execPath, [SCRIPT, mode], { encoding: 'utf8', stdio: 'pipe' }); + return 0; + } catch (err) { + return err.status; + } +}; + +describe('ErrorHandler uncaught exceptions', function() { + this.timeout(20000); + + it('should exit by default, so a process manager restarts a broken server', () => { + assert.strictEqual(run('default'), 1); + }); + + it('should keep serving when the request was answered and exit is disabled', () => { + assert.strictEqual(run('survive'), 0); + }); + + it('should exit even when disabled, if the exception happened outside a request', () => { + assert.strictEqual(run('no-context'), 1); + }); +}); diff --git a/packages/server/test/UnlessApiTest.js b/packages/server/test/UnlessApiTest.js new file mode 100644 index 00000000..800c26ab --- /dev/null +++ b/packages/server/test/UnlessApiTest.js @@ -0,0 +1,26 @@ +require('./init'); + +const assert = require('assert'); +const { unlessApi } = require('@igojs/server/src/api'); + +// The middlewares that only serve rendered pages must not run on an API +// request: the flash scope alone writes to the session on every GET, which +// made every JSON response set a session cookie nothing reads. +describe('unlessApi', function() { + const calls = []; + const wrapped = unlessApi((req, res, next) => { calls.push(req.path); next(); }); + + beforeEach(() => { calls.length = 0; }); + + it('should skip a view middleware on an API request', () => { + let reached = false; + wrapped({ path: '/api/books', headers: {} }, {}, () => { reached = true; }); + assert(reached); + assert.deepStrictEqual(calls, []); + }); + + it('should run it on a page request', () => { + wrapped({ path: '/books', headers: {} }, {}, () => {}); + assert.deepStrictEqual(calls, ['/books']); + }); +}); diff --git a/packages/server/test/api/problemTest.js b/packages/server/test/api/problemTest.js new file mode 100644 index 00000000..ad414239 --- /dev/null +++ b/packages/server/test/api/problemTest.js @@ -0,0 +1,36 @@ +require('../init'); + +const assert = require('assert'); +const problem = require('@igojs/server/src/api/problem'); + +describe('api/problem', function() { + + describe('problem', function() { + + it('should build an RFC 9457 document', () => { + assert.deepStrictEqual(problem.problem(404), { + type: 'about:blank', title: 'Not Found', status: 404 + }); + }); + + it('should carry detail and errors when given', () => { + const doc = problem.problem(400, { title: 'Validation failed', detail: 'nope', errors: [{ path: 'a' }] }); + assert.strictEqual(doc.detail, 'nope'); + assert.deepStrictEqual(doc.errors, [{ path: 'a' }]); + }); + + it('should title any status from the HTTP registry', () => { + assert.strictEqual(problem.problem(409).title, 'Conflict'); + assert.strictEqual(problem.problem(429).title, 'Too Many Requests'); + }); + + it('should fall back to a generic title on an unknown status', () => { + assert.strictEqual(problem.problem(799).title, 'Error'); + }); + + it('should carry an application type when given', () => { + assert.strictEqual(problem.problem(409, { type: '/problems/out-of-stock' }).type, + '/problems/out-of-stock'); + }); + }); +}); diff --git a/packages/server/test/api/requestTest.js b/packages/server/test/api/requestTest.js new file mode 100644 index 00000000..f8eb6455 --- /dev/null +++ b/packages/server/test/api/requestTest.js @@ -0,0 +1,40 @@ +require('../init'); + +const assert = require('assert'); +const config = require('@igojs/server').config; +const { isApiRequest } = require('@igojs/server/src/api/request'); + +describe('api/request', function() { + + describe('isApiRequest', function() { + + it('should recognize the api prefix', () => { + assert(isApiRequest({ path: '/api/books', headers: {} })); + assert(isApiRequest({ path: '/api', headers: {} })); + }); + + it('should not mistake a path that merely starts with the prefix', () => { + assert(!isApiRequest({ path: '/apidocs', headers: {} })); + }); + + it('should recognize a client asking for json', () => { + assert(isApiRequest({ path: '/books', headers: { accept: 'application/json' } })); + }); + + it('should leave a regular page request alone', () => { + assert(!isApiRequest({ path: '/books', headers: { accept: 'text/html' } })); + assert(!isApiRequest({ path: '/books', headers: {} })); + }); + + it('should follow a custom prefix', () => { + const initial = config.api.prefix; + config.api.prefix = '/v1'; + try { + assert(isApiRequest({ path: '/v1/books', headers: {} })); + assert(!isApiRequest({ path: '/api/books', headers: {} })); + } finally { + config.api.prefix = initial; + } + }); + }); +}); diff --git a/packages/server/test/api/typesTest.js b/packages/server/test/api/typesTest.js new file mode 100644 index 00000000..32880842 --- /dev/null +++ b/packages/server/test/api/typesTest.js @@ -0,0 +1,38 @@ +const assert = require('assert'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +const PROJECT = path.join(__dirname, '..', 'types', 'tsconfig.json'); + +// The .d.ts files are only exercised by a type checker: without this, a broken +// declaration would ship unnoticed. test/types/expect-errors.ts pins the +// errors that must fire — if inference degrades to `any`, tsc reports the +// @ts-expect-error directives as unused and this fails. +describe('api/types', function() { + this.timeout(60000); + + // TypeScript 7 restricts its exports map, so its internal paths cannot be + // resolved directly: locate the binary through the package manifest instead. + const findTsc = () => { + try { + const pkg = require.resolve('typescript/package.json'); + return path.join(path.dirname(pkg), 'bin', 'tsc'); + } catch { + return null; + } + }; + + it('should typecheck the declarations against a TypeScript consumer', function() { + // A skip here would say the declarations are fine when nothing checked + // them: typescript is a devDependency of the workspace, its absence is a + // broken install, not a reason to pass. + const tsc = findTsc(); + assert.ok(tsc, 'typescript is not installed: the declarations went unchecked'); + + try { + execFileSync(process.execPath, [tsc, '-p', PROJECT], { encoding: 'utf8', stdio: 'pipe' }); + } catch (err) { + assert.fail(`tsc reported errors:\n${err.stdout || err.message}`); + } + }); +}); diff --git a/packages/server/test/api/validateTest.js b/packages/server/test/api/validateTest.js new file mode 100644 index 00000000..11c41a38 --- /dev/null +++ b/packages/server/test/api/validateTest.js @@ -0,0 +1,63 @@ +require('../init'); + +const assert = require('assert'); +const express = require('express'); +const { z } = require('zod'); + +const validate = require('@igojs/server/src/api/validate'); + +const handler = (schemas = {}) => { + const fn = (req, res) => res.end(); + Object.assign(fn, schemas); + return fn; +}; + +describe('api/validate', function() { + + describe('schemasOf', function() { + + it('should find the schemas attached to a handler', () => { + const schema = z.object({ a: z.string() }); + const schemas = validate.schemasOf(handler({ body: schema, query: schema })); + assert.deepStrictEqual(Object.keys(schemas).sort(), ['body', 'query']); + }); + + it('should ignore a handler without schemas', () => { + assert.strictEqual(validate.schemasOf(handler()), null); + }); + + it('should ignore a property that is not a schema', () => { + assert.strictEqual(validate.schemasOf(handler({ body: { a: 1 } })), null); + }); + }); + + describe('apply', function() { + + it('should report body routes without a schema', () => { + const router = express.Router(); + router.post('/bulk', handler()); + assert.deepStrictEqual(validate.apply(router), ['POST /bulk']); + }); + + it('should not report a route that declares a schema', () => { + const router = express.Router(); + router.post('/', handler({ body: z.object({ a: z.string() }) })); + assert.deepStrictEqual(validate.apply(router), []); + }); + + it('should not report routes that carry no body', () => { + const router = express.Router(); + router.get('/', handler()); + router.delete('/:id', handler()); + assert.deepStrictEqual(validate.apply(router), []); + }); + + it('should walk nested routers', () => { + const nested = express.Router(); + nested.post('/deep', handler()); + const router = express.Router(); + router.use('/nested', nested); + assert.deepStrictEqual(validate.apply(router), ['POST /deep']); + }); + }); +}); diff --git a/packages/server/test/fixtures/uncaught.cjs b/packages/server/test/fixtures/uncaught.cjs new file mode 100644 index 00000000..471b3eef --- /dev/null +++ b/packages/server/test/fixtures/uncaught.cjs @@ -0,0 +1,39 @@ +// Driven by UncaughtExceptionTest: raises an uncaught exception in a child +// process so its exit code can be observed. +process.env.NODE_ENV = 'test'; + +const mode = process.argv[2]; + +const config = require('../../src/config'); +config.init(); +config.exitOnUncaughtException = mode === 'default'; + +const errorhandler = require('../../src/connect/errorhandler'); +const logger = require('../../src/logger'); + +logger.error = () => {}; + +const fakeReq = () => ({ + method: 'GET', originalUrl: '/x', url: '/x', path: '/x', protocol: 'http', + headers: { host: 'localhost' }, get: () => '', body: {}, session: {}, +}); + +const fakeRes = () => { + const res = { headersSent: false, statusCode: 200, setHeader: () => {} }; + res.status = (code) => { res.statusCode = code; return res; }; + res.render = () => res; + res.send = () => res; + res.json = () => res; + return res; +}; + +const raise = () => process.emit('uncaughtException', new Error('boom')); + +if (mode === 'no-context') { + raise(); +} else { + errorhandler.initContext({})(fakeReq(), fakeRes(), raise); +} + +// only reached when the handler chose not to exit +setTimeout(() => process.exit(0), 1500); diff --git a/packages/server/test/project/app/api/books/books.controller.js b/packages/server/test/project/app/api/books/books.controller.js new file mode 100644 index 00000000..4d313465 --- /dev/null +++ b/packages/server/test/project/app/api/books/books.controller.js @@ -0,0 +1,25 @@ + +const dto = require('./books.dto'); + +exports.index = (req, res) => { + res.json({ page: req.query.page, typeofPage: typeof req.query.page, status: req.query.status }); +}; +exports.index.query = dto.ListBooks; + +exports.create = (req, res) => { + res.status(201).json(dto.serialize({ id: 1, ...req.body })); +}; +exports.create.body = dto.CreateBook; + +exports.show = (req, res) => { + res.json(dto.serialize({ id: Number(req.params.id), title: 'Dune', pages: 412 })); +}; + +exports.boom = () => { + throw new Error('boom in api'); +}; + +// no schema: the boot-time warning must report it +exports.bulk = (req, res) => { + res.json({ ok: true }); +}; diff --git a/packages/server/test/project/app/api/books/books.dto.js b/packages/server/test/project/app/api/books/books.dto.js new file mode 100644 index 00000000..c8b3c5ff --- /dev/null +++ b/packages/server/test/project/app/api/books/books.dto.js @@ -0,0 +1,18 @@ + +const { z } = require('zod'); + +exports.CreateBook = z.object({ + title: z.string().min(1), + pages: z.number().int().positive(), +}); + +exports.ListBooks = z.object({ + page: z.coerce.number().int().min(1).default(1), + status: z.enum(['draft', 'published']).optional(), +}); + +exports.serialize = (book) => ({ + id: book.id, + title: book.title, + pages: book.pages, +}); diff --git a/packages/server/test/project/app/api/books/books.routes.js b/packages/server/test/project/app/api/books/books.routes.js new file mode 100644 index 00000000..3a28bed7 --- /dev/null +++ b/packages/server/test/project/app/api/books/books.routes.js @@ -0,0 +1,13 @@ + +const express = require('express'); +const controller = require('./books.controller'); + +const router = express.Router(); + +router.get('/', controller.index); +router.post('/', controller.create); +router.post('/bulk', controller.bulk); +router.get('/boom', controller.boom); +router.get('/:id', controller.show); + +module.exports = router; diff --git a/packages/server/test/project/app/routes.js b/packages/server/test/project/app/routes.js index dc9a801a..76f9cded 100644 --- a/packages/server/test/project/app/routes.js +++ b/packages/server/test/project/app/routes.js @@ -98,4 +98,6 @@ module.exports.init = function(app) { res.json({ flash: res.locals.flash }); }); + app.api('/books', require('./api/books/books.routes')); + }; diff --git a/packages/server/test/project/package.json b/packages/server/test/project/package.json new file mode 100644 index 00000000..a35c0d0c --- /dev/null +++ b/packages/server/test/project/package.json @@ -0,0 +1,5 @@ +{ + "name": "igo-test-project", + "version": "1.2.3", + "private": true +} diff --git a/packages/server/test/types/expect-errors.ts b/packages/server/test/types/expect-errors.ts new file mode 100644 index 00000000..d53282bb --- /dev/null +++ b/packages/server/test/types/expect-errors.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; +import type { ApiHandler } from '../../index'; + +const CreateBook = z.object({ title: z.string(), pages: z.number() }); + +// Each line below must be rejected by tsc: typesTest.js asserts on the codes. +export const create: ApiHandler<{ body: typeof CreateBook }> = (req, res) => { + // @ts-expect-error pages is a number, not a string + const wrongType: string = req.body.pages; + // @ts-expect-error subtitle is not part of the schema + const missing = req.body.subtitle; + // @ts-expect-error title is a string, not a number + req.body.title = 42; + res.json({ wrongType, missing }); +}; +create.body = CreateBook; diff --git a/packages/server/test/types/tsconfig.json b/packages/server/test/types/tsconfig.json new file mode 100644 index 00000000..19fc208e --- /dev/null +++ b/packages/server/test/types/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["*.ts"] +} diff --git a/packages/server/test/types/valid.ts b/packages/server/test/types/valid.ts new file mode 100644 index 00000000..3152988a --- /dev/null +++ b/packages/server/test/types/valid.ts @@ -0,0 +1,62 @@ +import { z } from 'zod'; +import { Router, type NextFunction, type Request, type RequestHandler, type Response } from 'express'; +import { Model } from '@igojs/db'; +import type { ApiHandler } from '../../index'; + +interface BookRow { id: number; title: string; pages: number } +class Book extends Model({ table: 'books', columns: ['id', 'title', 'pages'] }) {} + +export const found = async (): Promise => { + const book = await Book.find(1); + const { rows } = await Book.where({ pages: 412 }).page(1, 25).list(); + return book?.title ?? rows[0]?.title; +}; + +const CreateBook = z.object({ + title: z.string().min(1), + pages: z.number().int().positive(), +}); + +const ListBooks = z.object({ + page: z.coerce.number().int().min(1).default(1), + status: z.enum(['draft', 'published']).optional(), +}); + +export const create: ApiHandler<{ body: typeof CreateBook }> = (req, res) => { + const title: string = req.body.title; + const pages: number = req.body.pages; + const traceId: string = req.traceId; + res.status(201).json({ title, pages, traceId }); +}; +create.body = CreateBook; + +export const index: ApiHandler<{ query: typeof ListBooks }> = (req, res) => { + const page: number = req.query.page; + const status: 'draft' | 'published' | undefined = req.query.status; + res.json({ page, status }); +}; +index.query = ListBooks; + +// A guard in front of a paginated handler: Express takes the handlers of one +// route in a rest parameter, so the middleware used to pin the query type to +// ParsedQs and no overload matched. The ADR asks every API controller to cover +// its refused-access case, so this is the ordinary shape, not an edge case. +const guard: RequestHandler = (_req, _res, next) => next(); + +// A guard in front of a handler whose params are typed by a schema has to be +// generic on the params: a RequestHandler would pin them to ParamsDictionary. +const BookId = z.object({ id: z.coerce.number().int().positive() }); +export const destroy: ApiHandler<{ params: typeof BookId }> = (req, res) => { + const id: number = req.params.id; + res.status(204).json({ id }); +}; +destroy.params = BookId; + +const guardParams =

(_req: Request

, _res: Response, next: NextFunction) => next(); + +const router = Router(); +router.get('/books', index); +router.delete('/books/:id', guardParams, destroy); +router.get('/books/guarded', guard, index); +router.get('/books/twice', guard, guard, index); +router.post('/books', guard, create);