Page de connexion distincte avant la page de dépôt
`GET /` décide côté serveur, à chaque requête, laquelle des deux pages servir : `public/login.html` (aucune session valide — juste le bouton « Se connecter », aucune zone de dépôt dans le HTML) ou `public/upload.html` (session valide — nom d'utilisateur injecté et échappé, bouton « Se déconnecter »). Nouveau `src/routes/pages.ts`. Simplifications côté page de dépôt, permises par cette garantie serveur : plus de fetch `/auth/me` ni de bascule `authenticated` en JS, plus de retrait/réinsertion de la zone de dépôt dans le DOM — elle est toujours présente puisque la page n'est servie qu'aux sessions valides. Une session qui expire en cours d'usage (401 sur /targets ou /upload*) renvoie vers `/?login=expired`, qui affiche alors la vraie page de connexion avec le message adapté. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
615d0fd45a
commit
0962c2cc1f
6 changed files with 320 additions and 91 deletions
55
README.md
55
README.md
|
|
@ -1,10 +1,12 @@
|
|||
# mediaDrop
|
||||
|
||||
Petit serveur pour **déposer des fichiers médias** (images / vidéos / audio) sur une
|
||||
machine : une page web de glisser-déposer et un endpoint `POST /upload`.
|
||||
Authentification **OpenID Connect gérée côté serveur** : le serveur effectue le flux
|
||||
Authorization Code + PKCE, puis maintient une **session** (cookie chiffré). Les
|
||||
fichiers sont écrits dans un dossier local. Pas de base de données, pas de listing.
|
||||
machine : une page de connexion, une page de glisser-déposer, et un endpoint
|
||||
`POST /upload`. Authentification **OpenID Connect gérée côté serveur** : le serveur
|
||||
effectue le flux Authorization Code + PKCE, puis maintient une **session** (cookie
|
||||
chiffré). `GET /` sert l'une ou l'autre page selon la session — jamais les deux à la
|
||||
fois. Les fichiers sont écrits dans un dossier local. Pas de base de données, pas de
|
||||
listing.
|
||||
|
||||
## Prérequis
|
||||
|
||||
|
|
@ -49,8 +51,9 @@ pointeur.
|
|||
npm run dev
|
||||
```
|
||||
|
||||
Ouvrir <http://localhost:3000/>, cliquer **Se connecter** (redirection vers le
|
||||
fournisseur), puis glisser des fichiers.
|
||||
Ouvrir <http://localhost:3000/> : page de connexion, cliquer **Se connecter**
|
||||
(redirection vers le fournisseur) ; de retour sur `/`, la page de dépôt s'affiche —
|
||||
il ne reste plus qu'à glisser des fichiers.
|
||||
|
||||
En production :
|
||||
|
||||
|
|
@ -108,21 +111,27 @@ pouvant écrire les paquets de l'organisation.
|
|||
|
||||
## Endpoints
|
||||
|
||||
| Route | Auth | Rôle |
|
||||
| -------------------------------------- | ------- | ------------------------------------------------------------------------ |
|
||||
| `GET /` | non | Page de dépôt |
|
||||
| `GET /health` | non | Sonde : `{ "status": "ok", "uptime": 12.3 }` |
|
||||
| `GET /auth/login` | non | Démarre le flux OIDC (redirige vers le fournisseur) |
|
||||
| `GET /auth/callback` | non | Fin du flux : ouvre la session, redirige vers `/` |
|
||||
| `POST /auth/logout` | session | Ferme la session (+ déconnexion RP si disponible) |
|
||||
| `GET /auth/me` | non | `{ "authenticated": bool, "name"?: string }` |
|
||||
| `GET /config` | non | Réglages client : `{ "chunkPoolSize": number }` |
|
||||
| `GET /targets` | session | Cibles : `[{ "name", "label", "allowDirectories", "allowedMimeTypes" }]` |
|
||||
| `POST /upload?target=<nom>[&path=<p>]` | session | Dépôt direct d'un fichier `multipart/form-data`, champ `file` |
|
||||
| `POST /upload/init` | session | Démarre un upload fragmenté → `{ id, chunkSize, chunkCount }` |
|
||||
| `PUT /upload/:id/:index` | session | Envoie un morceau (corps brut `application/octet-stream`) |
|
||||
| `POST /upload/:id/complete` | session | Assemble et finalise → `201` + métadonnées |
|
||||
| `DELETE /upload/:id` | session | Abandonne (supprime le fichier partiel) |
|
||||
| Route | Auth | Rôle |
|
||||
| -------------------------------------- | ------- | ------------------------------------------------------------------------------- |
|
||||
| `GET /` | non | Page de connexion (`login.html`), ou de dépôt (`upload.html`) si session valide |
|
||||
| `GET /health` | non | Sonde : `{ "status": "ok", "uptime": 12.3 }` |
|
||||
| `GET /auth/login` | non | Démarre le flux OIDC (redirige vers le fournisseur) |
|
||||
| `GET /auth/callback` | non | Fin du flux : ouvre la session, redirige vers `/` |
|
||||
| `POST /auth/logout` | session | Ferme la session (+ déconnexion RP si disponible) |
|
||||
| `GET /auth/me` | non | `{ "authenticated": bool, "name"?: string }` |
|
||||
| `GET /config` | non | Réglages client : `{ "chunkPoolSize": number }` |
|
||||
| `GET /targets` | session | Cibles : `[{ "name", "label", "allowDirectories", "allowedMimeTypes" }]` |
|
||||
| `POST /upload?target=<nom>[&path=<p>]` | session | Dépôt direct d'un fichier `multipart/form-data`, champ `file` |
|
||||
| `POST /upload/init` | session | Démarre un upload fragmenté → `{ id, chunkSize, chunkCount }` |
|
||||
| `PUT /upload/:id/:index` | session | Envoie un morceau (corps brut `application/octet-stream`) |
|
||||
| `POST /upload/:id/complete` | session | Assemble et finalise → `201` + métadonnées |
|
||||
| `DELETE /upload/:id` | session | Abandonne (supprime le fichier partiel) |
|
||||
|
||||
`GET /` décide **côté serveur**, à chaque requête : sans session valide, `login.html`
|
||||
(rien d'autre que le bouton **Se connecter**, aucune zone de dépôt dans le HTML) ;
|
||||
avec une session valide, `upload.html` avec le nom d'utilisateur injecté (échappé) et
|
||||
`Se déconnecter`. Une session qui expire pendant l'utilisation de la page (401 sur
|
||||
`/targets` ou `/upload*`) renvoie le navigateur sur `/?login=expired`.
|
||||
|
||||
`POST /upload` — `?target=<nom>` choisit la cible (absent → **première** cible).
|
||||
`?path=<chemin relatif>` (URL-encodé) dépose dans un **sous-dossier** recréé sous
|
||||
|
|
@ -317,6 +326,7 @@ src/
|
|||
errors.ts classes d'erreur (AppError + dérivées)
|
||||
error-handler.ts gestionnaire d'erreurs global
|
||||
routes/
|
||||
pages.ts GET / (login.html ou upload.html selon la session, nom échappé)
|
||||
auth.ts GET /auth/login, /auth/callback, /auth/me ; POST /auth/logout
|
||||
health.ts GET /health
|
||||
upload.ts GET /targets ; POST /upload (dépôt direct)
|
||||
|
|
@ -329,5 +339,6 @@ src/
|
|||
media-types.ts liste blanche des types + extensions + jokers
|
||||
paths.ts assainissement des chemins relatifs (dépôt de dossier)
|
||||
public/
|
||||
index.html page de dépôt (login = lien vers /auth/login, sans build)
|
||||
login.html page de connexion (bouton -> /auth/login, sans build)
|
||||
upload.html page de dépôt, servie uniquement avec une session valide
|
||||
```
|
||||
|
|
|
|||
119
public/login.html
Normal file
119
public/login.html
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>mediaDrop — Connexion</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #f6f7f9;
|
||||
--card: #ffffff;
|
||||
--border: #d7dbe0;
|
||||
--text: #1c1f23;
|
||||
--muted: #6b7280;
|
||||
--accent: #2563eb;
|
||||
--err: #b91c1c;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #14171a;
|
||||
--card: #1d2125;
|
||||
--border: #33393f;
|
||||
--text: #e8eaed;
|
||||
--muted: #9aa3ad;
|
||||
--accent: #60a5fa;
|
||||
--err: #f87171;
|
||||
}
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem 1rem;
|
||||
font:
|
||||
15px/1.5 system-ui,
|
||||
-apple-system,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
main {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
p.sub {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 1.5rem;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
justify-items: center;
|
||||
}
|
||||
.btn {
|
||||
display: inline-block;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
padding: 0.6rem 1.4rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
#banner {
|
||||
color: var(--err);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<h1>mediaDrop</h1>
|
||||
<p class="sub">Connectez-vous pour déposer des fichiers médias.</p>
|
||||
</header>
|
||||
|
||||
<div class="card">
|
||||
<div id="banner" hidden></div>
|
||||
<a class="btn" href="/auth/login">Se connecter</a>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const banner = document.getElementById('banner');
|
||||
const loginParam = new URLSearchParams(location.search).get('login');
|
||||
if (loginParam === 'error') {
|
||||
banner.textContent = 'La connexion a échoué. Réessayez.';
|
||||
banner.hidden = false;
|
||||
} else if (loginParam === 'expired') {
|
||||
banner.textContent = 'La session a expiré. Reconnectez-vous.';
|
||||
banner.hidden = false;
|
||||
}
|
||||
if (loginParam) history.replaceState({}, '', location.pathname);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -217,14 +217,13 @@
|
|||
<main>
|
||||
<header>
|
||||
<h1>mediaDrop</h1>
|
||||
<p class="sub">Connectez-vous, puis glissez des fichiers médias pour les déposer.</p>
|
||||
<p class="sub">Glissez des fichiers médias pour les déposer.</p>
|
||||
</header>
|
||||
|
||||
<div class="card">
|
||||
<div class="authrow">
|
||||
<span id="authState" class="muted">Chargement…</span>
|
||||
<a id="loginLink" class="btn" href="/auth/login" hidden>Se connecter</a>
|
||||
<form id="logoutForm" method="post" action="/auth/logout" hidden>
|
||||
<span class="muted">Connecté : {{USER_NAME}}</span>
|
||||
<form method="post" action="/auth/logout">
|
||||
<button class="btn secondary" type="submit">Se déconnecter</button>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -259,13 +258,9 @@
|
|||
</main>
|
||||
|
||||
<script>
|
||||
const authState = document.getElementById('authState');
|
||||
const banner = document.getElementById('banner');
|
||||
const loginLink = document.getElementById('loginLink');
|
||||
const logoutForm = document.getElementById('logoutForm');
|
||||
const targetCard = document.getElementById('targetCard');
|
||||
const targetSelect = document.getElementById('targetSelect');
|
||||
const dropArea = document.getElementById('dropArea');
|
||||
const dropzone = document.getElementById('dropzone');
|
||||
const picker = document.getElementById('picker');
|
||||
const dirRow = document.getElementById('dirRow');
|
||||
|
|
@ -282,42 +277,18 @@
|
|||
let CHUNK_POOL = 30; // budget global de morceaux en vol ; remplacé par GET /config
|
||||
const CHUNK_RETRIES = 3;
|
||||
|
||||
let authenticated = false;
|
||||
let currentTarget = null;
|
||||
let currentTargetAllowsDirs = false;
|
||||
|
||||
// Les zones de dépôt ne sont présentes dans le DOM que si l'on est connecté.
|
||||
const dropAreaAnchor = queueRow;
|
||||
dropArea.remove();
|
||||
function setDropAreaPresent(present) {
|
||||
if (present) {
|
||||
if (!dropArea.isConnected) dropAreaAnchor.before(dropArea);
|
||||
} else if (dropArea.isConnected) {
|
||||
dropArea.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function showBanner(message) {
|
||||
banner.textContent = message;
|
||||
banner.hidden = false;
|
||||
}
|
||||
|
||||
const loginParam = new URLSearchParams(location.search).get('login');
|
||||
if (loginParam === 'error') showBanner('La connexion a échoué. Réessayez.');
|
||||
else if (loginParam === 'expired') showBanner('La session de connexion a expiré. Réessayez.');
|
||||
if (loginParam) history.replaceState({}, '', location.pathname);
|
||||
|
||||
function render(me) {
|
||||
authenticated = Boolean(me && me.authenticated);
|
||||
loginLink.hidden = authenticated;
|
||||
logoutForm.hidden = !authenticated;
|
||||
setDropAreaPresent(authenticated);
|
||||
authState.textContent = authenticated ? 'Connecté : ' + me.name : 'Non connecté';
|
||||
if (authenticated) {
|
||||
loadTargets();
|
||||
} else {
|
||||
targetCard.hidden = true;
|
||||
}
|
||||
// La session a expiré pendant l'utilisation de la page : la route `GET /`
|
||||
// sert la page de connexion tant qu'elle n'est pas renouvelée.
|
||||
function goToLogin() {
|
||||
location.href = '/?login=expired';
|
||||
}
|
||||
|
||||
let targets = [];
|
||||
|
|
@ -326,7 +297,7 @@
|
|||
const target = targets.find((t) => t.name === targetSelect.value) || targets[0] || null;
|
||||
currentTarget = target ? target.name : null;
|
||||
currentTargetAllowsDirs = Boolean(target && target.allowDirectories);
|
||||
dirRow.hidden = !authenticated || !currentTargetAllowsDirs;
|
||||
dirRow.hidden = !currentTargetAllowsDirs;
|
||||
// Filtre le sélecteur natif de fichiers selon les types acceptés par la cible
|
||||
// (le serveur reste l'autorité ; l'attribut ne bloque pas le glisser-déposer).
|
||||
const mimes = target && target.allowedMimeTypes;
|
||||
|
|
@ -338,7 +309,13 @@
|
|||
|
||||
function loadTargets() {
|
||||
fetch('/targets', { headers: { accept: 'application/json' } })
|
||||
.then((r) => (r.ok ? r.json() : []))
|
||||
.then((r) => {
|
||||
if (r.status === 401) {
|
||||
goToLogin();
|
||||
return [];
|
||||
}
|
||||
return r.ok ? r.json() : [];
|
||||
})
|
||||
.then((data) => {
|
||||
targets = Array.isArray(data) ? data : [];
|
||||
targetSelect.replaceChildren();
|
||||
|
|
@ -361,11 +338,7 @@
|
|||
}
|
||||
|
||||
targetSelect.addEventListener('change', applyTargetSelection);
|
||||
|
||||
fetch('/auth/me', { headers: { accept: 'application/json' } })
|
||||
.then((r) => r.json())
|
||||
.then(render)
|
||||
.catch(() => render({ authenticated: false }));
|
||||
loadTargets();
|
||||
|
||||
function formatSize(bytes) {
|
||||
const units = ['o', 'Ko', 'Mo', 'Go', 'To'];
|
||||
|
|
@ -578,9 +551,7 @@
|
|||
ui.progress.textContent = formatSize(job.file.size);
|
||||
setStatus(ui, 'Déposé : ' + (body.storedName || 'OK'), 'ok');
|
||||
} else if (status === 401) {
|
||||
render({ authenticated: false });
|
||||
showBanner('Session expirée — reconnectez-vous.');
|
||||
setStatus(ui, 'Non authentifié', 'err');
|
||||
goToLogin();
|
||||
} else {
|
||||
setStatus(ui, 'Erreur ' + status + ' : ' + (body.message || 'échec'), 'err');
|
||||
}
|
||||
|
|
@ -810,10 +781,6 @@
|
|||
|
||||
// items : tableau de { file, path } — path '' = fichier simple, sinon dépôt de dossier.
|
||||
function handleBatch(items) {
|
||||
if (!authenticated) {
|
||||
showBanner('Connectez-vous avant de déposer des fichiers.');
|
||||
return;
|
||||
}
|
||||
let batch = items;
|
||||
if (!currentTargetAllowsDirs && batch.some((entry) => entry.path)) {
|
||||
showBanner('La cible sélectionnée n’accepte pas le dépôt de dossiers.');
|
||||
|
|
@ -871,9 +838,7 @@
|
|||
return out;
|
||||
}
|
||||
|
||||
dropzone.addEventListener('click', () => {
|
||||
if (authenticated) picker.click();
|
||||
});
|
||||
dropzone.addEventListener('click', () => picker.click());
|
||||
picker.addEventListener('change', () => {
|
||||
handleBatch(fromFileList(picker.files, false));
|
||||
picker.value = '';
|
||||
|
|
@ -887,7 +852,7 @@
|
|||
['dragenter', 'dragover'].forEach((type) => {
|
||||
dropzone.addEventListener(type, (event) => {
|
||||
event.preventDefault();
|
||||
if (authenticated) dropzone.classList.add('hover');
|
||||
dropzone.classList.add('hover');
|
||||
});
|
||||
});
|
||||
['dragleave', 'drop'].forEach((type) => {
|
||||
|
|
@ -898,7 +863,7 @@
|
|||
});
|
||||
});
|
||||
dropzone.addEventListener('drop', (event) => {
|
||||
if (!authenticated || !event.dataTransfer) return;
|
||||
if (!event.dataTransfer) return;
|
||||
collectDropItems(event.dataTransfer)
|
||||
.then((items) => {
|
||||
if (items.length) handleBatch(items);
|
||||
18
src/app.ts
18
src/app.ts
|
|
@ -1,6 +1,3 @@
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import Fastify, { type FastifyInstance, type FastifyServerOptions } from 'fastify';
|
||||
import multipart from '@fastify/multipart';
|
||||
import secureSession from '@fastify/secure-session';
|
||||
|
|
@ -14,13 +11,9 @@ import { createOidcClient, type OidcClient } from './oidc.js';
|
|||
import { authRoutes } from './routes/auth.js';
|
||||
import { chunkedUploadRoutes } from './routes/upload-chunked.js';
|
||||
import { healthRoutes } from './routes/health.js';
|
||||
import { pageRoutes } from './routes/pages.js';
|
||||
import { uploadRoutes } from './routes/upload.js';
|
||||
|
||||
const INDEX_HTML = readFileSync(
|
||||
fileURLToPath(new URL('../public/index.html', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const SESSION_COOKIE_NAME = 'mediadrop_session';
|
||||
|
||||
export interface BuildAppOptions {
|
||||
|
|
@ -32,8 +25,8 @@ export interface BuildAppOptions {
|
|||
|
||||
/**
|
||||
* Construit l'instance Fastify : session chiffrée, façade OIDC, plugins, dossier
|
||||
* de dépôt, gestionnaire d'erreurs, page (`GET /`) et routes (`/auth/*`,
|
||||
* `/health`, `/upload`). Exportée pour les tests.
|
||||
* de dépôt, gestionnaire d'erreurs, page (`GET /` — connexion ou dépôt selon la
|
||||
* session) et routes (`/auth/*`, `/health`, `/upload`). Exportée pour les tests.
|
||||
*/
|
||||
export async function buildApp(
|
||||
config: AppConfig,
|
||||
|
|
@ -107,10 +100,7 @@ export async function buildApp(
|
|||
}
|
||||
registerErrorHandler(app);
|
||||
|
||||
app.get('/', (_request, reply): void => {
|
||||
reply.type('text/html; charset=utf-8').send(INDEX_HTML);
|
||||
});
|
||||
|
||||
await app.register(pageRoutes);
|
||||
await app.register(authRoutes);
|
||||
await app.register(healthRoutes);
|
||||
await app.register(uploadRoutes);
|
||||
|
|
|
|||
47
src/routes/pages.ts
Normal file
47
src/routes/pages.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import type { FastifyPluginCallback } from 'fastify';
|
||||
|
||||
import { getSessionUser } from '../session.js';
|
||||
|
||||
const LOGIN_HTML = readFileSync(
|
||||
fileURLToPath(new URL('../../public/login.html', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
const UPLOAD_HTML_TEMPLATE = readFileSync(
|
||||
fileURLToPath(new URL('../../public/upload.html', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const USER_NAME_PLACEHOLDER = '{{USER_NAME}}';
|
||||
|
||||
const HTML_ESCAPES: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
};
|
||||
|
||||
/** Échappe une valeur avant insertion dans du HTML brut (nom d'utilisateur issu de l'IdP). */
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(/[&<>"']/g, (char) => HTML_ESCAPES[char] ?? char);
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET /` — page de connexion tant qu'aucune session valide n'existe, sinon
|
||||
* page de dépôt (nom d'utilisateur injecté côté serveur, échappé).
|
||||
*/
|
||||
export const pageRoutes: FastifyPluginCallback = (app, _options, done) => {
|
||||
app.get('/', (request, reply): void => {
|
||||
const user = getSessionUser(request);
|
||||
const html =
|
||||
user === null
|
||||
? LOGIN_HTML
|
||||
: UPLOAD_HTML_TEMPLATE.replace(USER_NAME_PLACEHOLDER, escapeHtml(user.name));
|
||||
reply.type('text/html; charset=utf-8').send(html);
|
||||
});
|
||||
|
||||
done();
|
||||
};
|
||||
97
tests/pages.test.ts
Normal file
97
tests/pages.test.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import type { FastifyInstance } from 'fastify';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildApp } from '../src/app.js';
|
||||
import type { OidcConfig } from '../src/config.js';
|
||||
import {
|
||||
login,
|
||||
makeConfig,
|
||||
makeTempDir,
|
||||
oneTarget,
|
||||
removeDir,
|
||||
SESSION_COOKIE,
|
||||
startStubIdp,
|
||||
type StubIdp,
|
||||
} from './helpers.js';
|
||||
|
||||
let idp: StubIdp;
|
||||
let app: FastifyInstance;
|
||||
let uploadDir: string;
|
||||
|
||||
function oidcOverride(ref: StubIdp): OidcConfig {
|
||||
return {
|
||||
issuer: ref.issuer,
|
||||
clientId: ref.clientId,
|
||||
clientSecret: ref.clientSecret,
|
||||
scopes: ['openid', 'profile', 'email'],
|
||||
};
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
idp = await startStubIdp();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await idp.close();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
uploadDir = await makeTempDir();
|
||||
app = await buildApp(makeConfig({ targets: oneTarget(uploadDir), oidc: oidcOverride(idp) }), {
|
||||
logger: false,
|
||||
});
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
await removeDir(uploadDir);
|
||||
});
|
||||
|
||||
describe('GET /', () => {
|
||||
it('sans session : sert la page de connexion, pas la page de dépôt', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('text/html');
|
||||
expect(res.body).toContain('href="/auth/login"');
|
||||
expect(res.body).not.toContain('id="dropzone"');
|
||||
expect(res.body).not.toContain('Se déconnecter');
|
||||
});
|
||||
|
||||
it('avec session valide : sert la page de dépôt avec le nom injecté', async () => {
|
||||
const cookie = await login(app, idp, { name: 'Alice' });
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/',
|
||||
cookies: { [SESSION_COOKIE]: cookie },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toContain('Connecté : Alice');
|
||||
expect(res.body).toContain('Se déconnecter');
|
||||
expect(res.body).toContain('id="dropzone"');
|
||||
expect(res.body).not.toContain('{{USER_NAME}}');
|
||||
expect(res.body).not.toContain('href="/auth/login"');
|
||||
});
|
||||
|
||||
it('échappe le nom d’utilisateur avant de l’insérer dans le HTML', async () => {
|
||||
const cookie = await login(app, idp, { name: '<script>alert(1)</script>' });
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/',
|
||||
cookies: { [SESSION_COOKIE]: cookie },
|
||||
});
|
||||
expect(res.body).not.toContain('<script>alert(1)</script>');
|
||||
expect(res.body).toContain('<script>alert(1)</script>');
|
||||
});
|
||||
|
||||
it('session invalide (cookie illisible) : retombe sur la page de connexion', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/',
|
||||
cookies: { [SESSION_COOKIE]: 'cookie-bidon' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toContain('href="/auth/login"');
|
||||
expect(res.body).not.toContain('id="dropzone"');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue