WP-00: application foundation
Scaffolds the project: Next.js 16 App Router with strict TypeScript, Prisma 7 on PostgreSQL 16, Tailwind 4, Vitest, Playwright, CI, and a standalone Docker image that applies migrations on boot. Makes the no-tracker rule of PLAN.md 3.7 enforceable rather than stated. A per-request nonce-based CSP names no external origin, a unit test fails if any network directive gains one, and a second test fails if a tracking package appears in package.json. The end-to-end test drives the standalone server the Docker image runs, not `next dev`, so a proxy matcher that stopped matching could not pass unnoticed. Environment is validated at import, so a missing DATABASE_URL fails at boot with a readable message instead of surfacing later as a driver error mid-export. ENCRYPTION_KEY is checked to be 32 bytes. Three deviations from the plan, recorded in PLAN.md and README: Next 16 rather than 15, `proxy.ts` rather than the now-deprecated `middleware.ts`, and database-backed sessions rather than Auth.js v5, which is still beta and whose JWTs would make the session revocation required by compliance item 23 awkward. Verified locally against PostgreSQL 16: migrations apply, extensions created, typecheck, lint, 9 unit tests and the end-to-end header test all pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
32 files changed
+7533
-1
No files matched your search
@@ -0,0 +1,25 @@
|
||||
# Copier vers .env et renseigner. Ne jamais committer .env.
|
||||
|
||||
# --- Base de données --------------------------------------------------------
|
||||
POSTGRES_USER=planflow
|
||||
POSTGRES_PASSWORD=change-me
|
||||
POSTGRES_DB=planflow
|
||||
|
||||
# Utilisée par l'application et par Prisma.
|
||||
# En docker-compose l'hôte est `db` ; en développement local, `localhost`.
|
||||
DATABASE_URL=postgresql://planflow:change-me@localhost:5432/planflow
|
||||
|
||||
# --- Chiffrement ------------------------------------------------------------
|
||||
# Chiffre au repos les colonnes sensibles exigées par PLAN.md §3.6 :
|
||||
# NIR, IBAN, BIC. 32 octets en base64.
|
||||
#
|
||||
# openssl rand -base64 32
|
||||
#
|
||||
# Cette clé vit hors de la base : une sauvegarde volée ne doit pas suffire à
|
||||
# lire ces colonnes. La perdre rend les données chiffrées irrécupérables —
|
||||
# la sauvegarder séparément et documenter sa rotation.
|
||||
ENCRYPTION_KEY=
|
||||
|
||||
# --- Application ------------------------------------------------------------
|
||||
APP_URL=http://localhost:3000
|
||||
APP_PORT=3000
|
||||
@@ -0,0 +1,71 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
# Test-only values. Real secrets never live in CI configuration.
|
||||
DATABASE_URL: postgresql://planflow:planflow@localhost:5432/planflow_test
|
||||
ENCRYPTION_KEY: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
|
||||
APP_URL: http://127.0.0.1:3100
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_USER: planflow
|
||||
POSTGRES_PASSWORD: planflow
|
||||
POSTGRES_DB: planflow_test
|
||||
ports: ['5432:5432']
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U planflow"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- run: pnpm db:generate
|
||||
|
||||
- name: Appliquer les migrations
|
||||
run: pnpm db:deploy
|
||||
|
||||
- run: pnpm typecheck
|
||||
|
||||
- run: pnpm lint
|
||||
|
||||
- run: pnpm test
|
||||
|
||||
- run: pnpm build
|
||||
|
||||
- name: Installer le navigateur Playwright
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- run: pnpm test:e2e
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 7
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
|
||||
.next/
|
||||
out/
|
||||
build/
|
||||
next-env.d.ts
|
||||
*.tsbuildinfo
|
||||
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
coverage/
|
||||
test-results/
|
||||
playwright-report/
|
||||
blob-report/
|
||||
.playwright/
|
||||
|
||||
.DS_Store
|
||||
*.log
|
||||
.tmp/
|
||||
.pgdata/
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM node:22-alpine AS base
|
||||
RUN corepack enable
|
||||
WORKDIR /app
|
||||
|
||||
# ---- dependencies -----------------------------------------------------------
|
||||
FROM base AS deps
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
COPY prisma ./prisma
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# ---- build ------------------------------------------------------------------
|
||||
FROM base AS build
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN pnpm db:generate && pnpm build
|
||||
|
||||
# ---- runtime ----------------------------------------------------------------
|
||||
FROM base AS runner
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs \
|
||||
&& adduser --system --uid 1001 --ingroup nodejs nextjs
|
||||
|
||||
# `output: standalone` emits a server bundle carrying only the modules it uses.
|
||||
COPY --from=build --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=build --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=build --chown=nextjs:nodejs /app/public ./public
|
||||
|
||||
# Migrations run at startup, so the image can be deployed without a separate
|
||||
# migration step. prisma/ and its CLI are needed for that.
|
||||
COPY --from=build --chown=nextjs:nodejs /app/prisma ./prisma
|
||||
COPY --from=build --chown=nextjs:nodejs /app/node_modules/prisma ./node_modules/prisma
|
||||
COPY --from=build --chown=nextjs:nodejs /app/node_modules/.bin/prisma ./node_modules/.bin/prisma
|
||||
COPY --from=build --chown=nextjs:nodejs /app/node_modules/@prisma ./node_modules/@prisma
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000 HOSTNAME=0.0.0.0
|
||||
|
||||
CMD ["sh", "-c", "./node_modules/.bin/prisma migrate deploy && node server.js"]
|
||||
@@ -60,9 +60,21 @@ Huit constats structurants, qu'une lecture de la documentation publique de Combo
|
||||
| **Paie** | **Export vers Silae** (§8). Aucun moteur de paie, aucun bulletin, aucune DSN dans PlanFlow. |
|
||||
| **Pointeuse** | **Hors périmètre.** Pas de borne, pas de PWA kiosque, pas de pointage matériel. Les heures réelles sont saisies et validées par le manager (§7.3). |
|
||||
| **Convention d'amorce** | **IDCC 1517**, moteur paramétrable pour en ajouter d'autres. |
|
||||
| **Stack** | Next.js 15 (App Router, TypeScript strict) · PostgreSQL 16 + Prisma · Auth.js v5 · Tailwind + shadcn/ui · Zod · Vitest + Playwright · pnpm · `docker-compose` auto-hébergé. |
|
||||
| **Stack** | Next.js 16 (App Router, TypeScript strict) · PostgreSQL 16 + Prisma 7 · **sessions maison en base** · Tailwind 4 · Zod · Vitest + Playwright · pnpm · `docker-compose` auto-hébergé. |
|
||||
| **Mobile** | PWA installable, responsive. Pas d'application native. |
|
||||
|
||||
### Écarts constatés à la réalisation
|
||||
|
||||
Trois choix de §2 ont été révisés au WP-00, après confrontation aux versions réellement disponibles.
|
||||
|
||||
| Sujet | Plan initial | Retenu | Raison |
|
||||
|---|---|---|---|
|
||||
| Next.js | 15 | **16.3** | Version stable courante. Démarrer une majeure en retard n'apporte rien. |
|
||||
| Authentification | Auth.js v5 | **Sessions en base** | Auth.js v5 est toujours en beta. Le besoin se limite à identifiants et invitation, sans OAuth — et la matrice n° 23 impose la **révocation de session**, immédiate avec des sessions en base, malaisée avec des JWT. |
|
||||
| Convention | `middleware.ts` | **`proxy.ts`** | Next 16 a renommé la convention ; `middleware` est déprécié. |
|
||||
|
||||
Prisma 7 a par ailleurs déplacé l'URL de connexion du schéma vers `prisma.config.ts`, et l'application passe désormais par un adaptateur `pg` explicite — c'est ce point d'accroche qui recevra l'extension de scoping multi-tenant au WP-01.
|
||||
|
||||
**Hors périmètre v1**, à ne pas construire : moteur de paie, DSN, bulletins de paie, distribution de bulletins, signature électronique qualifiée, transmission DPAE à l'URSSAF, connecteurs de caisse, abonnement et facturation, planning prédictif, auto-assignation.
|
||||
|
||||
**Conservés mais différés en fin de parcours** : articles et conversations internes (WP-11, optionnel). Les analyses RH sont bien dans le périmètre v1 (WP-09).
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# PlanFlow
|
||||
|
||||
Gestion du personnel, des plannings et des temps, multi-établissements, auto-hébergée. La paie est **exportée vers Silae** ; PlanFlow ne produit ni bulletin ni DSN.
|
||||
|
||||
La spécification de construction est [`PLAN.md`](PLAN.md). Elle est normative : en cas d'écart entre le code et le plan, c'est le plan qui a raison, ou le plan qui doit être corrigé — jamais l'écart qui s'installe.
|
||||
|
||||
| Document | Rôle |
|
||||
|---|---|
|
||||
| [`PLAN.md`](PLAN.md) | Spécification : périmètre, modèle de données, règles, lots de travail |
|
||||
| [`matrice-conformite-rh-france-2026.md`](matrice-conformite-rh-france-2026.md) | Exigences réglementaires françaises, cotées P0/P1/P2 |
|
||||
| [`Audit Combo/`](Audit%20Combo/INDEX.md) | Audit fonctionnel du produit de référence |
|
||||
|
||||
## État
|
||||
|
||||
**WP-00 — socle.** Next.js, Prisma, base de données, en-têtes de sécurité, tests, CI, image Docker. Aucun écran métier : ils arrivent à partir de WP-01 (tenancy, identité, autorisation).
|
||||
|
||||
## Démarrer
|
||||
|
||||
### Avec Docker
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Renseigner POSTGRES_PASSWORD et ENCRYPTION_KEY (voir ci-dessous)
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
L'application écoute sur <http://localhost:3000>. Les migrations s'appliquent au démarrage du conteneur.
|
||||
|
||||
### En local
|
||||
|
||||
Nécessite Node 22, pnpm 10 et un PostgreSQL 16 accessible.
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
cp .env.example .env # renseigner DATABASE_URL et ENCRYPTION_KEY
|
||||
pnpm db:generate
|
||||
pnpm db:deploy
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### Clé de chiffrement
|
||||
|
||||
`ENCRYPTION_KEY` chiffre au repos les colonnes sensibles exigées par le plan (§3.6) : NIR, IBAN, BIC.
|
||||
|
||||
```bash
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
Elle vit **hors de la base** : une sauvegarde volée ne doit pas suffire à lire ces colonnes. La perdre rend ces données irrécupérables — la sauvegarder séparément et documenter sa rotation.
|
||||
|
||||
## Vérifier
|
||||
|
||||
```bash
|
||||
pnpm verify # typecheck + lint + tests unitaires
|
||||
pnpm test:e2e # build, serveur standalone, tests de bout en bout
|
||||
```
|
||||
|
||||
`pnpm verify` est ce que la CI exécute sur chaque *pull request*, suivi du build et des tests end-to-end.
|
||||
|
||||
## Choix structurants
|
||||
|
||||
**Aucun traceur tiers.** L'audit du produit de référence a intercepté 2102 requêtes de traçage — Segment, LinkedIn Ads, Google Ads, DoubleClick, Clarity, Hotjar — et aucune requête métier. Une application RH ne doit pas envoyer un contexte de navigation portant sur des salariés identifiables à des régies publicitaires. Deux garde-fous rendent la règle vérifiable plutôt que déclarative :
|
||||
|
||||
- une Content-Security-Policy qui ne nomme **aucune** origine externe, posée par requête avec un nonce (`src/proxy.ts`) ;
|
||||
- un test qui échoue si une dépendance de traçage apparaît dans `package.json`.
|
||||
|
||||
Pour de la télémétrie technique, passer par une interface abstraite auto-hébergée.
|
||||
|
||||
**Le serveur testé est celui qui est déployé.** Les tests end-to-end lancent le serveur `standalone`, celui que l'image Docker exécute — pas `next dev`, dont la politique de sécurité est volontairement plus permissive.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/ écrans (App Router)
|
||||
├── lib/
|
||||
│ ├── env.ts contrat d'environnement, validé à l'import
|
||||
│ └── security/csp.ts politique de sécurité, fonction pure et testable
|
||||
├── server/
|
||||
│ ├── db.ts client Prisma — le scoping multi-tenant s'y greffe au WP-01
|
||||
│ └── health.ts
|
||||
└── proxy.ts en-têtes de sécurité par requête
|
||||
prisma/ schéma et migrations
|
||||
tests/
|
||||
├── unit/ Vitest
|
||||
└── e2e/ Playwright
|
||||
```
|
||||
|
||||
## Écarts assumés par rapport au plan
|
||||
|
||||
Trois choix diffèrent de ce qu'annonçait `PLAN.md` §2, et le plan a été mis à jour en conséquence.
|
||||
|
||||
| Sujet | Plan initial | Retenu | Raison |
|
||||
|---|---|---|---|
|
||||
| Next.js | 15 | **16.3** | Version stable courante ; démarrer un greenfield une majeure en retard n'a pas de contrepartie. |
|
||||
| Authentification | Auth.js v5 | **Sessions maison en base** | Auth.js v5 est encore en beta. Le besoin se limite à identifiants et invitation, sans OAuth, et la matrice de conformité (n° 23) impose la **révocation de session** — immédiate avec des sessions en base, malaisée avec des jetons JWT. |
|
||||
| Convention Next | `middleware.ts` | **`proxy.ts`** | Next 16 a renommé la convention ; `middleware` est déprécié. |
|
||||
@@ -0,0 +1,42 @@
|
||||
name: planflow
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-planflow}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD est requis}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-planflow}
|
||||
# Deterministic collation: ordering of employee names must not depend on
|
||||
# the host locale, or exports differ between machines.
|
||||
LANG: C.UTF-8
|
||||
volumes:
|
||||
- db-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-planflow} -d ${POSTGRES_DB:-planflow}']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
# Not published by default: nothing outside the compose network needs the
|
||||
# database, and an HR dataset should not be one firewall rule from the world.
|
||||
expose:
|
||||
- '5432'
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-planflow}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-planflow}
|
||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY:?ENCRYPTION_KEY est requis — voir .env.example}
|
||||
APP_URL: ${APP_URL:-http://localhost:3000}
|
||||
ports:
|
||||
- '${APP_PORT:-3000}:3000'
|
||||
|
||||
volumes:
|
||||
db-data:
|
||||
@@ -0,0 +1,33 @@
|
||||
import coreWebVitals from 'eslint-config-next/core-web-vitals';
|
||||
import typescript from 'eslint-config-next/typescript';
|
||||
|
||||
/**
|
||||
* eslint-config-next 16 ships flat configs directly, so no FlatCompat bridge.
|
||||
*/
|
||||
const config = [
|
||||
{
|
||||
ignores: [
|
||||
'node_modules/**',
|
||||
'.next/**',
|
||||
'next-env.d.ts',
|
||||
'playwright-report/**',
|
||||
'test-results/**',
|
||||
'.pgdata/**',
|
||||
],
|
||||
},
|
||||
...coreWebVitals,
|
||||
...typescript,
|
||||
{
|
||||
rules: {
|
||||
// An unused binding is usually a leftover, and a leftover in an
|
||||
// authorisation path is a security bug. The _ prefix marks the ones that
|
||||
// are deliberate.
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// Docker image: ship only what the server needs.
|
||||
output: 'standalone',
|
||||
reactStrictMode: true,
|
||||
poweredByHeader: false,
|
||||
|
||||
// Security headers that do not need a per-request nonce.
|
||||
// The Content-Security-Policy is set in src/proxy.ts because it does.
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/:path*',
|
||||
headers: [
|
||||
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
||||
{ key: 'X-Frame-Options', value: 'DENY' },
|
||||
{ key: 'Referrer-Policy', value: 'no-referrer' },
|
||||
{
|
||||
key: 'Strict-Transport-Security',
|
||||
value: 'max-age=63072000; includeSubDomains; preload',
|
||||
},
|
||||
// No browser feature in this app needs these. Denying them keeps a
|
||||
// future dependency from silently reaching for a camera or a GPS fix.
|
||||
{
|
||||
key: 'Permissions-Policy',
|
||||
value: 'camera=(), microphone=(), geolocation=(), interest-cohort=()',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "planflow",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"db:generate": "prisma generate",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:deploy": "prisma migrate deploy",
|
||||
"db:seed": "tsx prisma/seed.ts",
|
||||
"db:studio": "prisma studio",
|
||||
"verify": "pnpm typecheck && pnpm lint && pnpm test"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@node-rs/argon2": "2.0.2",
|
||||
"@prisma/adapter-pg": "7.9.1",
|
||||
"@prisma/client": "7.9.1",
|
||||
"next": "16.3.0",
|
||||
"pg": "8.22.0",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.62.1",
|
||||
"@tailwindcss/postcss": "4.3.3",
|
||||
"@types/node": "22.19.4",
|
||||
"@types/pg": "8.20.4",
|
||||
"@types/react": "19.2.18",
|
||||
"@types/react-dom": "19.2.4",
|
||||
"dotenv": "^17.4.2",
|
||||
"eslint": "9.39.1",
|
||||
"eslint-config-next": "16.3.0",
|
||||
"prisma": "7.9.1",
|
||||
"tailwindcss": "4.3.3",
|
||||
"tsx": "4.20.6",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "4.1.10"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@prisma/client",
|
||||
"@prisma/engines",
|
||||
"esbuild",
|
||||
"prisma",
|
||||
"unrs-resolver"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
const PORT = Number(process.env.E2E_PORT ?? 3100);
|
||||
const baseURL = `http://127.0.0.1:${PORT}`;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: Boolean(process.env.CI),
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
// Serial in CI so the single Postgres service is not raced.
|
||||
...(process.env.CI ? { workers: 1 } : {}),
|
||||
reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : 'list',
|
||||
|
||||
use: {
|
||||
baseURL,
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
],
|
||||
|
||||
webServer: {
|
||||
// Exercise the standalone server, which is what the Docker image runs.
|
||||
// `next start` is not compatible with `output: standalone`, and testing a
|
||||
// different server than the one deployed defeats the purpose of this suite.
|
||||
command: [
|
||||
'pnpm build',
|
||||
'cp -r .next/static .next/standalone/.next/static',
|
||||
'cp -r public .next/standalone/public',
|
||||
`node .next/standalone/server.js`,
|
||||
].join(' && '),
|
||||
url: baseURL,
|
||||
env: { PORT: String(PORT), HOSTNAME: '127.0.0.1' },
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 180_000,
|
||||
},
|
||||
});
|
||||
Generated
+6462
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'dotenv/config';
|
||||
import { defineConfig, env } from 'prisma/config';
|
||||
|
||||
/**
|
||||
* Prisma 7 moved the migration connection URL out of schema.prisma.
|
||||
*
|
||||
* Only the CLI reads this file. The application connects through the pg driver
|
||||
* adapter in src/server/db.ts, which is what lets WP-01 wrap every query in the
|
||||
* tenant-scoping extension required by PLAN.md §3.1.
|
||||
*/
|
||||
export default defineConfig({
|
||||
schema: 'prisma/schema.prisma',
|
||||
migrations: {
|
||||
path: 'prisma/migrations',
|
||||
seed: 'tsx prisma/seed.ts',
|
||||
},
|
||||
datasource: {
|
||||
url: env('DATABASE_URL'),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Extensions PostgreSQL dont dépendent les lots suivants.
|
||||
--
|
||||
-- citext : adresses e-mail insensibles à la casse, sans dupliquer un champ
|
||||
-- normalisé à côté de la valeur saisie.
|
||||
-- pgcrypto: primitives utilisées par les contrôles d'intégrité (empreintes
|
||||
-- d'exports, PLAN.md §8.3). Le chiffrement des colonnes sensibles
|
||||
-- reste applicatif, clé hors base (PLAN.md §3.6).
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS "citext";
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g. Git)
|
||||
provider = "postgresql"
|
||||
@@ -0,0 +1,14 @@
|
||||
// PlanFlow — schéma de données.
|
||||
//
|
||||
// WP-00 n'installe que la plomberie : datasource, generator et les extensions
|
||||
// PostgreSQL dont les lots suivants dépendent. Les modèles métier arrivent au
|
||||
// WP-01 (tenancy, identité, autorisation) — voir PLAN.md §4.
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
// Prisma 7 : l'URL de connexion vit dans prisma.config.ts, plus ici.
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
}
|
||||
Whitespace-only changes.
@@ -0,0 +1 @@
|
||||
@import 'tailwindcss';
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'PlanFlow',
|
||||
description: 'Gestion du personnel, des plannings et des temps.',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html lang="fr">
|
||||
<body className="min-h-dvh bg-neutral-50 text-neutral-900 antialiased">
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { checkDatabase } from '@/server/health';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function HomePage() {
|
||||
const database = await checkDatabase();
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-dvh max-w-2xl flex-col justify-center gap-6 p-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">PlanFlow</h1>
|
||||
<p className="mt-2 text-neutral-600">
|
||||
Socle applicatif — lot WP-00. Les écrans métier arrivent aux lots suivants.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl className="grid gap-3 rounded-lg border border-neutral-200 bg-white p-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<dt className="text-sm text-neutral-600">Base de données</dt>
|
||||
<dd
|
||||
className={
|
||||
database.ok
|
||||
? 'text-sm font-medium text-emerald-700'
|
||||
: 'text-sm font-medium text-red-700'
|
||||
}
|
||||
>
|
||||
{database.ok ? 'connectée' : `indisponible — ${database.error}`}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Environment contract, validated once at import.
|
||||
*
|
||||
* A missing DATABASE_URL should fail at boot with a readable message, not
|
||||
* surface later as an opaque driver error in the middle of a payroll export.
|
||||
*/
|
||||
const schema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||
|
||||
DATABASE_URL: z.url({ protocol: /^postgres(ql)?$/ }),
|
||||
|
||||
/**
|
||||
* Application-level encryption key for the columns PLAN.md §3.6 requires at
|
||||
* rest (NIR, IBAN, BIC). 32 bytes, base64. Kept out of the database so a dump
|
||||
* alone does not disclose them.
|
||||
*/
|
||||
ENCRYPTION_KEY: z
|
||||
.string()
|
||||
.refine(
|
||||
(value) => Buffer.from(value, 'base64').length === 32,
|
||||
'ENCRYPTION_KEY doit être 32 octets encodés en base64',
|
||||
),
|
||||
|
||||
/** Public origin, used for links in invitation and notification e-mails. */
|
||||
APP_URL: z.url().default('http://localhost:3000'),
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof schema>;
|
||||
|
||||
function load(): Env {
|
||||
const parsed = schema.safeParse(process.env);
|
||||
|
||||
if (!parsed.success) {
|
||||
const details = parsed.error.issues
|
||||
.map((issue) => ` - ${issue.path.join('.')}: ${issue.message}`)
|
||||
.join('\n');
|
||||
throw new Error(`Configuration d'environnement invalide :\n${details}`);
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
export const env = load();
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Content-Security-Policy for PlanFlow.
|
||||
*
|
||||
* PLAN.md §3.7 bans third-party analytics and advertising trackers. The audit of
|
||||
* the reference product intercepted 2102 such requests and no business call at
|
||||
* all; an HR application must not leak employee-context navigation to ad
|
||||
* networks. This policy names no external origin, so any dependency that tries
|
||||
* is stopped by the browser.
|
||||
*
|
||||
* Kept as a pure function so the rule is unit-testable without booting Next.
|
||||
* Applied per request in src/proxy.ts.
|
||||
*/
|
||||
|
||||
export interface CspOptions {
|
||||
/** Per-request nonce, base64. Next.js needs it for its own inline scripts. */
|
||||
nonce: string;
|
||||
/** Dev needs 'unsafe-eval' for React Refresh. Never enable it in production. */
|
||||
isDevelopment?: boolean;
|
||||
}
|
||||
|
||||
/** Directives that must never name a host other than 'self'. */
|
||||
export const NETWORK_DIRECTIVES = [
|
||||
'default-src',
|
||||
'script-src',
|
||||
'connect-src',
|
||||
'img-src',
|
||||
'style-src',
|
||||
'font-src',
|
||||
'frame-src',
|
||||
] as const;
|
||||
|
||||
export function buildContentSecurityPolicy({
|
||||
nonce,
|
||||
isDevelopment = false,
|
||||
}: CspOptions): string {
|
||||
const scriptSrc = [
|
||||
"'self'",
|
||||
`'nonce-${nonce}'`,
|
||||
// Lets modern browsers trust scripts loaded by a nonce-verified script,
|
||||
// while older ones fall back to 'self'.
|
||||
"'strict-dynamic'",
|
||||
...(isDevelopment ? ["'unsafe-eval'"] : []),
|
||||
];
|
||||
|
||||
const directives: Record<string, string[]> = {
|
||||
'default-src': ["'self'"],
|
||||
'script-src': scriptSrc,
|
||||
// Tailwind injects styles at build time, but React still sets inline
|
||||
// style attributes; 'unsafe-inline' for styles carries no script risk.
|
||||
'style-src': ["'self'", "'unsafe-inline'"],
|
||||
'img-src': ["'self'", 'blob:', 'data:'],
|
||||
'font-src': ["'self'"],
|
||||
'connect-src': ["'self'"],
|
||||
'frame-src': ["'none'"],
|
||||
'object-src': ["'none'"],
|
||||
'base-uri': ["'self'"],
|
||||
'form-action': ["'self'"],
|
||||
'frame-ancestors': ["'none'"],
|
||||
'upgrade-insecure-requests': [],
|
||||
};
|
||||
|
||||
return Object.entries(directives)
|
||||
.map(([directive, values]) =>
|
||||
values.length > 0 ? `${directive} ${values.join(' ')}` : directive,
|
||||
)
|
||||
.join('; ');
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextResponse, type NextRequest } from 'next/server';
|
||||
|
||||
import { buildContentSecurityPolicy } from '@/lib/security/csp';
|
||||
|
||||
/**
|
||||
* Emits a per-request nonce and the Content-Security-Policy built from it.
|
||||
*
|
||||
* PLAN.md §3.7 forbids third-party trackers. A policy that names no external
|
||||
* origin is what makes that rule enforceable rather than merely stated: a
|
||||
* dependency that starts phoning home is blocked by the browser instead of
|
||||
* quietly succeeding.
|
||||
*
|
||||
* Next.js 16 renamed the `middleware` convention to `proxy`.
|
||||
*/
|
||||
export function proxy(request: NextRequest): NextResponse {
|
||||
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
|
||||
const csp = buildContentSecurityPolicy({
|
||||
nonce,
|
||||
isDevelopment: process.env.NODE_ENV === 'development',
|
||||
});
|
||||
|
||||
// Server Components read the nonce from the request headers.
|
||||
const requestHeaders = new Headers(request.headers);
|
||||
requestHeaders.set('x-nonce', nonce);
|
||||
requestHeaders.set('content-security-policy', csp);
|
||||
|
||||
const response = NextResponse.next({ request: { headers: requestHeaders } });
|
||||
response.headers.set('content-security-policy', csp);
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
// Everything except static assets, which need no policy of their own.
|
||||
{
|
||||
source: '/((?!_next/static|_next/image|favicon.ico).*)',
|
||||
missing: [
|
||||
{ type: 'header', key: 'next-router-prefetch' },
|
||||
{ type: 'header', key: 'purpose', value: 'prefetch' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { env } from '@/lib/env';
|
||||
|
||||
/**
|
||||
* Prisma client singleton.
|
||||
*
|
||||
* WP-01 wraps this with the tenant-scoping extension required by PLAN.md §3.1,
|
||||
* so every query is filtered by the session's account and scope, with
|
||||
* PostgreSQL row-level security behind it as defence in depth. Nothing outside
|
||||
* this module should construct a client.
|
||||
*/
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined;
|
||||
};
|
||||
|
||||
function createClient(): PrismaClient {
|
||||
const adapter = new PrismaPg({ connectionString: env.DATABASE_URL });
|
||||
|
||||
return new PrismaClient({
|
||||
adapter,
|
||||
log: env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'],
|
||||
});
|
||||
}
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? createClient();
|
||||
|
||||
if (env.NODE_ENV !== 'production') {
|
||||
globalForPrisma.prisma = prisma;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { prisma } from '@/server/db';
|
||||
|
||||
export interface HealthResult {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Round-trips a trivial query so the landing page reports real connectivity. */
|
||||
export async function checkDatabase(): Promise<HealthResult> {
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : 'erreur inconnue',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* The unit tests prove the policy builder is correct. This proves the running
|
||||
* application actually sends it — a middleware matcher that quietly stops
|
||||
* matching would pass every unit test and ship an unprotected app.
|
||||
*/
|
||||
test('les en-têtes de sécurité sont servis par l’application', async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.get('/');
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
const headers = response.headers();
|
||||
|
||||
const csp = headers['content-security-policy'];
|
||||
expect(csp, 'aucune Content-Security-Policy servie').toBeTruthy();
|
||||
expect(csp).toContain("default-src 'self'");
|
||||
expect(csp).toContain("frame-ancestors 'none'");
|
||||
expect(csp).toMatch(/script-src [^;]*'nonce-/);
|
||||
|
||||
expect(headers['x-frame-options']).toBe('DENY');
|
||||
expect(headers['x-content-type-options']).toBe('nosniff');
|
||||
expect(headers['referrer-policy']).toBe('no-referrer');
|
||||
expect(headers['permissions-policy']).toContain('geolocation=()');
|
||||
|
||||
// Next.js advertises itself by default; there is no reason to tell an
|
||||
// attacker which framework and version to look up.
|
||||
expect(headers['x-powered-by']).toBeUndefined();
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildContentSecurityPolicy,
|
||||
NETWORK_DIRECTIVES,
|
||||
} from '@/lib/security/csp';
|
||||
|
||||
function parse(policy: string): Map<string, string[]> {
|
||||
return new Map(
|
||||
policy.split('; ').map((directive) => {
|
||||
const [name, ...values] = directive.split(' ');
|
||||
return [name ?? '', values];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Any source that is neither a keyword, a nonce, nor a safe scheme. */
|
||||
function externalOrigins(values: string[]): string[] {
|
||||
return values.filter((value) => {
|
||||
if (value.startsWith("'")) return false; // 'self', 'none', 'nonce-…', …
|
||||
if (value === 'blob:' || value === 'data:') return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
describe('Content-Security-Policy', () => {
|
||||
const policy = buildContentSecurityPolicy({ nonce: 'dGVzdC1ub25jZQ==' });
|
||||
const directives = parse(policy);
|
||||
|
||||
it('names no external origin on any network directive', () => {
|
||||
// PLAN.md §3.7 — the audited product shipped Segment, LinkedIn Ads, Google
|
||||
// Ads, DoubleClick, Clarity, Hotjar and Bugsnag. This assertion is what
|
||||
// keeps that from creeping back in as a "small" addition.
|
||||
for (const directive of NETWORK_DIRECTIVES) {
|
||||
const values = directives.get(directive) ?? [];
|
||||
expect(
|
||||
externalOrigins(values),
|
||||
`${directive} autorise une origine tierce`,
|
||||
).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('carries the request nonce on script-src', () => {
|
||||
expect(directives.get('script-src')).toContain("'nonce-dGVzdC1ub25jZQ=='");
|
||||
});
|
||||
|
||||
it('never allows unsafe-eval outside development', () => {
|
||||
expect(policy).not.toContain("'unsafe-eval'");
|
||||
});
|
||||
|
||||
it('allows unsafe-eval in development only, for React Refresh', () => {
|
||||
const devPolicy = buildContentSecurityPolicy({
|
||||
nonce: 'dGVzdA==',
|
||||
isDevelopment: true,
|
||||
});
|
||||
expect(devPolicy).toContain("'unsafe-eval'");
|
||||
});
|
||||
|
||||
it('forbids inline scripts, framing and object embedding', () => {
|
||||
expect(directives.get('script-src')).not.toContain("'unsafe-inline'");
|
||||
expect(directives.get('frame-ancestors')).toEqual(["'none'"]);
|
||||
expect(directives.get('object-src')).toEqual(["'none'"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* The env module validates at import, so each case needs a fresh module
|
||||
* registry with process.env set beforehand.
|
||||
*/
|
||||
async function loadEnv(values: Record<string, string | undefined>) {
|
||||
const previous = { ...process.env };
|
||||
process.env = { ...previous, ...values } as NodeJS.ProcessEnv;
|
||||
try {
|
||||
vi.resetModules();
|
||||
return await import('@/lib/env');
|
||||
} finally {
|
||||
process.env = previous;
|
||||
}
|
||||
}
|
||||
|
||||
const VALID_KEY = Buffer.alloc(32, 7).toString('base64');
|
||||
|
||||
describe('configuration d’environnement', () => {
|
||||
it('accepte une configuration complète', async () => {
|
||||
const { env } = await loadEnv({
|
||||
DATABASE_URL: 'postgresql://user:pass@localhost:5432/planflow',
|
||||
ENCRYPTION_KEY: VALID_KEY,
|
||||
APP_URL: 'https://planflow.example',
|
||||
});
|
||||
|
||||
expect(env.DATABASE_URL).toContain('planflow');
|
||||
expect(env.APP_URL).toBe('https://planflow.example');
|
||||
});
|
||||
|
||||
it('refuse une clé de chiffrement qui ne fait pas 32 octets', async () => {
|
||||
await expect(
|
||||
loadEnv({
|
||||
DATABASE_URL: 'postgresql://user:pass@localhost:5432/planflow',
|
||||
ENCRYPTION_KEY: Buffer.alloc(16, 1).toString('base64'),
|
||||
}),
|
||||
).rejects.toThrow(/ENCRYPTION_KEY/);
|
||||
});
|
||||
|
||||
it('refuse une URL de base non PostgreSQL', async () => {
|
||||
await expect(
|
||||
loadEnv({
|
||||
DATABASE_URL: 'mysql://user:pass@localhost:3306/planflow',
|
||||
ENCRYPTION_KEY: VALID_KEY,
|
||||
}),
|
||||
).rejects.toThrow(/DATABASE_URL/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* PLAN.md §3.7 bans analytics and advertising dependencies outright.
|
||||
*
|
||||
* The CSP stops such a package at runtime; this stops it at review time, with a
|
||||
* message that says why. The audited product carried ten of these, so the
|
||||
* failure mode is not hypothetical — it is what happens when nobody is looking.
|
||||
*/
|
||||
const BANNED = [
|
||||
'segment',
|
||||
'@segment/',
|
||||
'analytics-node',
|
||||
'react-ga',
|
||||
'gtag',
|
||||
'google-analytics',
|
||||
'mixpanel',
|
||||
'amplitude',
|
||||
'hotjar',
|
||||
'clarity-js',
|
||||
'satismeter',
|
||||
'fullstory',
|
||||
'logrocket',
|
||||
'bugsnag',
|
||||
'sentry',
|
||||
'datadog',
|
||||
'posthog',
|
||||
'heap-analytics',
|
||||
'intercom',
|
||||
];
|
||||
|
||||
interface PackageJson {
|
||||
dependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
}
|
||||
|
||||
describe('dépendances', () => {
|
||||
const packageJson: PackageJson = JSON.parse(
|
||||
readFileSync(
|
||||
fileURLToPath(new URL('../../package.json', import.meta.url)),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
|
||||
const installed = [
|
||||
...Object.keys(packageJson.dependencies ?? {}),
|
||||
...Object.keys(packageJson.devDependencies ?? {}),
|
||||
];
|
||||
|
||||
it('ne contient aucun traceur publicitaire ou analytique', () => {
|
||||
const offenders = installed.filter((name) =>
|
||||
BANNED.some((banned) => name.toLowerCase().includes(banned)),
|
||||
);
|
||||
|
||||
expect(
|
||||
offenders,
|
||||
'PLAN.md §3.7 interdit les traceurs tiers dans une application RH. ' +
|
||||
"Pour de la télémétrie technique, passer par l'interface abstraite " +
|
||||
'auto-hébergée plutôt que par un service externe.',
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"ES2022"
|
||||
],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
".next",
|
||||
"tests/e2e"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['tests/unit/**/*.test.ts'],
|
||||
globals: false,
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user