Écran de première installation
Les migrations posent le schéma et rien d'autre : une instance neuve n'a aucun compte et aucun utilisateur, donc personne ne peut se connecter. Le seed n'y remédie pas — il installe une démonstration et refuse de tourner en production, à raison. La première visite est désormais redirigée vers `/installation` : nom de l'entreprise, premier établissement avec son fuseau, et compte administrateur. L'écran crée le compte, le catalogue des capacités, les cinq rôles fournis, le propriétaire et son périmètre, puis ouvre la session par le chemin ordinaire — le rôle propriétaire exige aussitôt un second facteur, comme il se doit. Il ne se rouvre pas. Une table `Installation` d'une seule ligne, contrainte en base et protégée par un trigger append-only, marque l'instance. Elle est délibérément hors RLS, et c'est sa raison d'être : la politique d'`Account` ne laisse voir que le compte courant, si bien qu'une instance installée paraîtrait vierge à qui n'a pas de session — et la création d'un propriétaire se rouvrirait à tout venant. Le recensement des politiques porte l'exception, affirmée dans les deux sens. Deux défauts trouvés en éprouvant l'écran sur une base réellement vierge : `INSERT ... RETURNING` sur `Account` était refusé. L'insertion est permise, mais la relecture de la ligne écrite passe par la politique de lecture, qui exige un compte courant. L'identifiant est donc tiré côté application et annoncé avant la création — la règle de partout, appliquée à la transaction qui crée le compte. Et un défaut qui dépassait cet écran : React 19 vide les champs non contrôlés dès qu'une action se termine, refus compris. Les champs vidés portant `required`, le clic suivant était arrêté par la validation du navigateur avant d'émettre un `submit` — le formulaire paraissait mort. Le formulaire de connexion en souffrait aussi ; la suite l'avait manqué parce qu'aucun test ne soumettait deux fois de suite. `PersistentForm` photographie la saisie à l'envoi et la rétablit, mots de passe exclus. Au passage, `Field` rattache son indication par `aria-describedby` : placée dans le `<label>`, elle entrait dans le nom accessible du champ. Éprouvé sur une base vierge, image de production, rôle NOSUPERUSER NOBYPASSRLS : redirection, trois refus motivés, installation, second facteur exigé, écran refermé pour le propriétaire comme pour un visiteur. 450 tests unitaires et d'intégration, 77 tests de bout en bout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
21 files changed
+1367
-11
No files matched your search
@@ -35,6 +35,39 @@ test('un mot de passe faux ne dit pas si le compte existe', async ({ page }) =>
|
||||
expect(knownAccount).toContain('Identifiants incorrects');
|
||||
});
|
||||
|
||||
test('un formulaire refusé se soumet une deuxième fois', async ({ page }) => {
|
||||
// Régression. React 19 vide les champs non contrôlés dès qu'une action se
|
||||
// termine, refus compris. Les champs vidés portant `required`, le clic
|
||||
// suivant était arrêté par la validation du navigateur **avant** d'émettre un
|
||||
// `submit` : le formulaire paraissait mort, le bouton répondait, et rien ne
|
||||
// partait. Tous les écrans de l'application étaient concernés, et la suite
|
||||
// l'avait manqué parce qu'aucun test ne soumettait deux fois de suite.
|
||||
//
|
||||
// Une adresse inconnue, à dessein : deux échecs sur un compte réel
|
||||
// rapprocheraient les autres tests du verrouillage.
|
||||
await page.goto('/connexion');
|
||||
const alert = page.locator('form').getByRole('alert');
|
||||
const email = page.getByLabel('Adresse électronique');
|
||||
|
||||
await email.fill('inconnu@example.test');
|
||||
await page.getByLabel('Mot de passe').fill('premier-essai-faux');
|
||||
await page.getByRole('button', { name: 'Se connecter' }).click();
|
||||
await expect(alert).toContainText('Identifiants incorrects');
|
||||
|
||||
// Ce que l'utilisateur avait tapé est toujours là — sauf le mot de passe,
|
||||
// qui n'est jamais réécrit dans le document.
|
||||
await expect(email).toHaveValue('inconnu@example.test');
|
||||
|
||||
// Le second envoi doit **aboutir**. Réaffirmer le même refus ne prouverait
|
||||
// rien : le message précédent reste à l'écran, et un formulaire mort le
|
||||
// laisserait tel quel. Une connexion réussie, elle, ne peut venir que du
|
||||
// serveur.
|
||||
await email.fill('salarie@example.test');
|
||||
await page.getByLabel('Mot de passe').fill(PASSWORD);
|
||||
await page.getByRole('button', { name: 'Se connecter' }).click();
|
||||
await expect(page).not.toHaveURL(/\/connexion/);
|
||||
});
|
||||
|
||||
test('connexion, navigation, puis déconnexion', async ({ page }) => {
|
||||
const remembered = rememberedSecret();
|
||||
// Un code ne sert qu'une fois : attendre le pas suivant celui qu'a employé la
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Première installation — PLAN.md §5.
|
||||
*
|
||||
* La base de cette suite est semée, donc **installée**. Ce que ces tests
|
||||
* peuvent prouver est justement ce qui compte le plus : l'écran ne se rouvre
|
||||
* pas. Il crée un propriétaire sans demander de session ; laissé accessible
|
||||
* après coup, il donnerait à n'importe quel visiteur un compte disposant de
|
||||
* tous les droits sur l'instance.
|
||||
*
|
||||
* La création elle-même est éprouvée contre une base réelle dans
|
||||
* tests/integration/installation.test.ts, où la transaction est annulée — le
|
||||
* marqueur d'installation, lui, ne se supprime pas.
|
||||
*/
|
||||
|
||||
test('l’écran d’installation est refermé sur une instance installée', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/installation');
|
||||
|
||||
// Redirigé vers la connexion, et non pas seulement privé de son formulaire.
|
||||
await expect(page).toHaveURL(/\/connexion/);
|
||||
await expect(page.getByRole('heading', { name: 'Connexion' })).toBeVisible();
|
||||
|
||||
// Le formulaire ne doit pas non plus subsister dans le HTML : un champ caché
|
||||
// reste soumettable.
|
||||
expect(await page.content()).not.toContain('Première installation');
|
||||
});
|
||||
|
||||
test('la connexion reste la porte d’entrée', async ({ page }) => {
|
||||
// Le pendant du test précédent : sur une instance installée, la racine ne
|
||||
// doit pas dériver vers l'installation. Les deux redirections se répondent,
|
||||
// et une erreur de sens les ferait boucler l'une sur l'autre.
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveURL(/\/connexion/);
|
||||
await expect(page.getByRole('heading', { name: 'Connexion' })).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { adminPrisma } from './admin-db';
|
||||
|
||||
import { DEFAULT_TIMEZONE, type InstallationForm } from '@/domain/install/rules';
|
||||
import { PERMISSION_CODES } from '@/domain/access/permissions';
|
||||
import { installAccount, INSTALLATION_ID } from '@/server/install/install';
|
||||
import { unscoped } from '@/server/tenant';
|
||||
|
||||
/**
|
||||
* Première installation, branchée sur la base — PLAN.md §5.
|
||||
*
|
||||
* Ce que les tests de règles ne peuvent pas montrer : que l'installation passe
|
||||
* **par la connexion de l'application**, soumise à la row-level security. C'est
|
||||
* le point délicat. Les politiques d'insertion exigent
|
||||
* `accountId = planflow_current_account()`, et le compte n'existe pas encore
|
||||
* quand la transaction s'ouvre. Si le réglage du compte courant venait à
|
||||
* manquer, chaque écriture serait refusée et l'instance resterait vide.
|
||||
*
|
||||
* Tout se joue dans une transaction **annulée** : le marqueur d'installation,
|
||||
* lui, ne se supprime pas — c'est sa raison d'être — donc un test qui le
|
||||
* poserait pour de bon condamnerait la base de développement.
|
||||
*/
|
||||
|
||||
const enabled = (process.env.ADMIN_DATABASE_URL ?? process.env.DATABASE_URL ?? '')
|
||||
.length > 0;
|
||||
const describeIfDb = enabled ? describe : describe.skip;
|
||||
|
||||
/** Sentinelle d'annulation : sortir par une erreur est ce qui défait tout. */
|
||||
class Rollback extends Error {}
|
||||
|
||||
function form(overrides: Partial<InstallationForm> = {}): InstallationForm {
|
||||
const unique = Date.now();
|
||||
return {
|
||||
companyName: `Entreprise ${unique}`,
|
||||
locationName: 'Siège',
|
||||
timezone: DEFAULT_TIMEZONE,
|
||||
firstName: 'Camille',
|
||||
lastName: 'Ferrand',
|
||||
email: `Install.${unique}@Exemple.Test`,
|
||||
password: 'quatre chevaux blancs',
|
||||
passwordConfirmation: 'quatre chevaux blancs',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Joue l'installation puis annule. `fn` reçoit la transaction : c'est le seul
|
||||
* moment où le travail est visible.
|
||||
*/
|
||||
async function installThenRollback(
|
||||
input: InstallationForm,
|
||||
fn: (
|
||||
tx: Prisma.TransactionClient,
|
||||
instance: Awaited<ReturnType<typeof installAccount>>,
|
||||
) => Promise<void>,
|
||||
): Promise<void> {
|
||||
await expect(
|
||||
unscoped().$transaction(
|
||||
async (tx) => {
|
||||
const instance = await installAccount(tx, input, 'empreinte-factice');
|
||||
await fn(tx, instance);
|
||||
throw new Rollback();
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
),
|
||||
).rejects.toBeInstanceOf(Rollback);
|
||||
}
|
||||
|
||||
describeIfDb('première installation', () => {
|
||||
it('pose un compte utilisable depuis la connexion de l’application', async () => {
|
||||
const input = form();
|
||||
|
||||
await installThenRollback(input, async (tx, instance) => {
|
||||
const account = await tx.account.findUniqueOrThrow({
|
||||
where: { id: instance.accountId },
|
||||
});
|
||||
expect(account.name).toBe(input.companyName);
|
||||
|
||||
// Cinq rôles fournis, et le propriétaire détient tout : c'est lui qui
|
||||
// délègue ensuite. Un propriétaire amputé d'une capacité ne pourrait
|
||||
// jamais l'accorder à personne.
|
||||
const roles = await tx.role.findMany({
|
||||
where: { accountId: instance.accountId },
|
||||
});
|
||||
expect(roles).toHaveLength(5);
|
||||
expect(roles.every((role) => role.isSystem)).toBe(true);
|
||||
|
||||
const owner = roles.find((role) => role.key === 'owner');
|
||||
if (!owner) throw new Error('Rôle propriétaire absent.');
|
||||
const ownerPermissions = await tx.rolePermission.count({
|
||||
where: { roleId: owner.id },
|
||||
});
|
||||
expect(ownerPermissions).toBe(PERMISSION_CODES.length);
|
||||
|
||||
// L'employé, lui, ne doit surtout pas tout recevoir.
|
||||
const employee = roles.find((role) => role.key === 'employee');
|
||||
if (!employee) throw new Error('Rôle employé absent.');
|
||||
const employeePermissions = await tx.rolePermission.count({
|
||||
where: { roleId: employee.id },
|
||||
});
|
||||
expect(employeePermissions).toBeGreaterThan(0);
|
||||
expect(employeePermissions).toBeLessThan(PERMISSION_CODES.length);
|
||||
});
|
||||
});
|
||||
|
||||
it('crée l’établissement avec le fuseau demandé', async () => {
|
||||
// Le fuseau décide des durées travaillées, changement d'heure compris : le
|
||||
// perdre en route fausserait les compteurs sans rien signaler.
|
||||
await installThenRollback(
|
||||
form({ timezone: 'America/Martinique' }),
|
||||
async (tx, instance) => {
|
||||
const location = await tx.location.findFirstOrThrow({
|
||||
where: { accountId: instance.accountId },
|
||||
});
|
||||
expect(location.timezone).toBe('America/Martinique');
|
||||
expect(location.name).toBe('Siège');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('rattache le propriétaire à tous les établissements', async () => {
|
||||
await installThenRollback(form(), async (tx, instance) => {
|
||||
const membership = await tx.membership.findUniqueOrThrow({
|
||||
where: { id: instance.membershipId },
|
||||
include: { scopes: true, role: true, profile: true },
|
||||
});
|
||||
|
||||
expect(membership.status).toBe('ACTIVE');
|
||||
expect(membership.role.key).toBe('owner');
|
||||
expect(membership.employeeNumber).toBe('E0001');
|
||||
expect(membership.profile?.lastName).toBe('Ferrand');
|
||||
|
||||
// `allLocations` plutôt qu'un rattachement nommé : sans cela, la personne
|
||||
// qui installe l'instance perdrait la vue le jour du deuxième
|
||||
// établissement.
|
||||
expect(membership.scopes).toHaveLength(1);
|
||||
expect(membership.scopes[0]?.allLocations).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('replie la casse de l’adresse de connexion', async () => {
|
||||
const input = form();
|
||||
|
||||
await installThenRollback(input, async (tx, instance) => {
|
||||
const user = await tx.user.findUniqueOrThrow({
|
||||
where: { id: instance.userId },
|
||||
});
|
||||
expect(user.email).toBe(input.email.toLowerCase());
|
||||
expect(user.passwordHash).toBe('empreinte-factice');
|
||||
});
|
||||
});
|
||||
|
||||
it('date l’installation dans le journal', async () => {
|
||||
await installThenRollback(form(), async (tx, instance) => {
|
||||
const entry = await tx.auditLog.findFirstOrThrow({
|
||||
where: { accountId: instance.accountId, action: 'install.complete' },
|
||||
});
|
||||
expect(entry.entityId).toBe(instance.accountId);
|
||||
expect(entry.actorMembershipId).toBe(instance.membershipId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describeIfDb('marqueur d’installation', () => {
|
||||
it('rapporte une instance semée comme installée', async () => {
|
||||
// C'est la première ligne de `installAction` : sans ce constat, l'action
|
||||
// rouvrirait la création d'un propriétaire sur une instance en service.
|
||||
const { isInstalled, forgetInstallationState } = await import(
|
||||
'@/server/install/state'
|
||||
);
|
||||
forgetInstallationState();
|
||||
await expect(isInstalled()).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('n’admet pas une seconde ligne', async () => {
|
||||
// Le garde-fou est en base, pas seulement dans l'action : deux requêtes
|
||||
// simultanées sur une instance vierge franchissent toutes deux le contrôle
|
||||
// applicatif, et c'est la clé primaire qui départage.
|
||||
const existing = await adminPrisma().installation.findFirst();
|
||||
expect(existing, 'la base de test doit être semée').not.toBeNull();
|
||||
|
||||
await expect(
|
||||
unscoped().$transaction(async (tx) => {
|
||||
// Même précaution que l'installation elle-même : la politique de
|
||||
// lecture d'`Account` n'ouvre que le compte courant, et `create`
|
||||
// relit la ligne qu'il écrit.
|
||||
const accountId = randomUUID();
|
||||
await tx.$executeRaw`SELECT set_config('app.account_id', ${accountId}, true)`;
|
||||
await tx.account.create({
|
||||
data: { id: accountId, name: `Doublon ${Date.now()}` },
|
||||
});
|
||||
await tx.installation.create({
|
||||
data: { id: INSTALLATION_ID, accountId },
|
||||
});
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'P2002' });
|
||||
});
|
||||
|
||||
it('refuse toute modification et toute suppression', async () => {
|
||||
// Une instance installée ne redevient pas vierge sur une requête de
|
||||
// l'application : sans cela, effacer une ligne rouvrirait la création d'un
|
||||
// propriétaire à n'importe quel visiteur.
|
||||
const db = adminPrisma();
|
||||
|
||||
await expect(
|
||||
db.installation.update({
|
||||
where: { id: INSTALLATION_ID },
|
||||
data: { installedAt: new Date() },
|
||||
}),
|
||||
).rejects.toThrow(/append-only/);
|
||||
|
||||
await expect(
|
||||
db.installation.delete({ where: { id: INSTALLATION_ID } }),
|
||||
).rejects.toThrow(/append-only/);
|
||||
});
|
||||
});
|
||||
@@ -178,6 +178,19 @@ describeIfDb('immutabilité du journal d’audit', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Tables qui portent `accountId` sans être soumises à la RLS.
|
||||
*
|
||||
* Une seule, et elle doit le rester : `Installation` répond à « cette instance
|
||||
* a-t-elle déjà un compte ? » **avant** qu'un compte soit connu. La lui poser
|
||||
* sous RLS reviendrait à demander à la politique du compte courant de parler
|
||||
* d'un compte qu'on n'a pas — elle répondrait « rien », et l'écran de première
|
||||
* installation, qui crée un propriétaire, se rouvrirait à tout venant.
|
||||
*
|
||||
* Ce qui la protège à la place est vérifié par le test suivant.
|
||||
*/
|
||||
const EXEMPT = new Set(['Installation']);
|
||||
|
||||
describeIfDb('couverture des politiques', () => {
|
||||
let client: Client;
|
||||
|
||||
@@ -221,7 +234,8 @@ describeIfDb('couverture des politiques', () => {
|
||||
|
||||
const unprotected = rows.filter(
|
||||
(row) =>
|
||||
!row.relrowsecurity || !row.relforcerowsecurity || row.policies < 2,
|
||||
!EXEMPT.has(row.table_name) &&
|
||||
(!row.relrowsecurity || !row.relforcerowsecurity || row.policies < 2),
|
||||
);
|
||||
|
||||
expect(
|
||||
@@ -229,4 +243,31 @@ describeIfDb('couverture des politiques', () => {
|
||||
'tables sans RLS forcée ou sans politique de lecture et d’écriture',
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('la seule table exemptée l’est bien, et autrement protégée', async () => {
|
||||
// Une exemption qui n'est que l'absence d'une règle finit par en couvrir
|
||||
// d'autres. Celle-ci est donc affirmée dans les deux sens : `Installation`
|
||||
// est hors RLS **et** ce qui la protège à la place existe réellement.
|
||||
const { rows } = await client.query<{ relrowsecurity: boolean }>(
|
||||
`SELECT relrowsecurity FROM pg_class WHERE relname = 'Installation'`,
|
||||
);
|
||||
expect(rows[0]?.relrowsecurity, 'Installation est hors RLS à dessein').toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
// Ce qui tient lieu de protection : la ligne ne se réécrit pas, ne
|
||||
// s'efface pas, et ne peut pas être doublée.
|
||||
const { rows: guards } = await client.query<{
|
||||
triggers: number;
|
||||
checks: number;
|
||||
}>(`
|
||||
SELECT
|
||||
(SELECT count(*)::int FROM pg_trigger
|
||||
WHERE tgrelid = '"Installation"'::regclass AND NOT tgisinternal) AS triggers,
|
||||
(SELECT count(*)::int FROM pg_constraint
|
||||
WHERE conrelid = '"Installation"'::regclass AND contype = 'c') AS checks
|
||||
`);
|
||||
expect(guards[0]?.triggers, 'trigger append-only').toBeGreaterThan(0);
|
||||
expect(guards[0]?.checks, 'contrainte de ligne unique').toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
DEFAULT_TIMEZONE,
|
||||
installationProblem,
|
||||
isKnownTimezone,
|
||||
normaliseInstallation,
|
||||
type InstallationForm,
|
||||
} from '@/domain/install/rules';
|
||||
|
||||
/**
|
||||
* Règles de la première installation — PLAN.md §5.
|
||||
*
|
||||
* Cet écran est le seul à s'ouvrir sans session, et il ne se rouvre pas : un
|
||||
* refus mal placé enferme l'exploitant dehors sans recours, un refus manquant
|
||||
* pose un propriétaire avec un mot de passe faible. Les deux se vérifient ici,
|
||||
* sans base ni navigateur.
|
||||
*/
|
||||
|
||||
function form(overrides: Partial<InstallationForm> = {}): InstallationForm {
|
||||
return {
|
||||
companyName: 'Maison Rivage',
|
||||
locationName: 'Nantes Atlantis',
|
||||
timezone: DEFAULT_TIMEZONE,
|
||||
firstName: 'Camille',
|
||||
lastName: 'Ferrand',
|
||||
email: 'camille@exemple.test',
|
||||
password: 'quatre chevaux blancs',
|
||||
passwordConfirmation: 'quatre chevaux blancs',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('installationProblem', () => {
|
||||
it('accepte un formulaire complet', () => {
|
||||
expect(installationProblem(form())).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['companyName', 'Le nom de l’entreprise'],
|
||||
['locationName', 'Le nom de l’établissement'],
|
||||
['firstName', 'Le prénom'],
|
||||
['lastName', 'Le nom'],
|
||||
] as const)('exige %s', (field, expected) => {
|
||||
const problem = installationProblem(form({ [field]: ' ' }));
|
||||
expect(problem).toContain(expected);
|
||||
});
|
||||
|
||||
it('refuse un fuseau inconnu', () => {
|
||||
// Un fuseau faux ne se voit qu'au premier calcul d'horaire, c'est-à-dire
|
||||
// sur une paie.
|
||||
expect(installationProblem(form({ timezone: 'Europe/Atlantide' }))).toMatch(
|
||||
/fuseau horaire .* est inconnu/,
|
||||
);
|
||||
});
|
||||
|
||||
it('accepte un fuseau hors de France', () => {
|
||||
// L'application est française, son fuseau par défaut aussi ; elle n'a pas
|
||||
// à interdire un établissement à Fort-de-France.
|
||||
expect(
|
||||
installationProblem(form({ timezone: 'America/Martinique' })),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('refuse une adresse sans arobase', () => {
|
||||
expect(installationProblem(form({ email: 'camille.exemple.test' }))).toMatch(
|
||||
/forme valide/,
|
||||
);
|
||||
});
|
||||
|
||||
it('accepte une adresse à sous-adressage', () => {
|
||||
// `+` et points sont légitimes ; les refuser fermerait la porte à des
|
||||
// adresses réelles sur un écran qui ne se rouvre pas.
|
||||
expect(
|
||||
installationProblem(form({ email: 'camille+planflow@exemple.test' })),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('refuse un mot de passe trop court', () => {
|
||||
expect(
|
||||
installationProblem(
|
||||
form({ password: 'court', passwordConfirmation: 'court' }),
|
||||
),
|
||||
).toMatch(/12 caractères/);
|
||||
});
|
||||
|
||||
it('refuse un mot de passe contenant le nom, accent compris', () => {
|
||||
expect(
|
||||
installationProblem(
|
||||
form({
|
||||
lastName: 'Rivière',
|
||||
password: 'riviere2026!!',
|
||||
passwordConfirmation: 'riviere2026!!',
|
||||
}),
|
||||
),
|
||||
).toMatch(/ne doit pas contenir votre nom/);
|
||||
});
|
||||
|
||||
it('refuse une confirmation divergente', () => {
|
||||
expect(
|
||||
installationProblem(form({ passwordConfirmation: 'quatre chevaux noirs' })),
|
||||
).toMatch(/ne correspondent pas/);
|
||||
});
|
||||
|
||||
it('juge le mot de passe après les champs qui le contextualisent', () => {
|
||||
// Sans cet ordre, un nom vide ferait passer « martin2026 » : la règle
|
||||
// « ne contient pas votre nom » n'aurait rien à comparer.
|
||||
const problem = installationProblem(
|
||||
form({ lastName: '', password: 'court', passwordConfirmation: 'court' }),
|
||||
);
|
||||
expect(problem).toContain('Le nom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normaliseInstallation', () => {
|
||||
it('replie la casse de l’adresse et rogne les espaces', () => {
|
||||
// L'adresse sert d'identifiant de connexion : la casse ne doit pas décider
|
||||
// si quelqu'un entre ou non.
|
||||
const data = normaliseInstallation(
|
||||
form({ email: ' Camille@Exemple.Test ', companyName: ' Maison Rivage ' }),
|
||||
);
|
||||
expect(data.email).toBe('camille@exemple.test');
|
||||
expect(data.companyName).toBe('Maison Rivage');
|
||||
});
|
||||
|
||||
it('ne touche pas au mot de passe', () => {
|
||||
// Rogner un mot de passe changerait celui que l'utilisateur croit avoir
|
||||
// choisi, et le refus suivant serait incompréhensible.
|
||||
const data = normaliseInstallation(
|
||||
form({ password: ' quatre chevaux ', passwordConfirmation: ' quatre chevaux ' }),
|
||||
);
|
||||
expect(data.password).toBe(' quatre chevaux ');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isKnownTimezone', () => {
|
||||
it('reconnaît un fuseau IANA et rejette une invention', () => {
|
||||
expect(isKnownTimezone('Europe/Paris')).toBe(true);
|
||||
expect(isKnownTimezone('Pacific/Auckland')).toBe(true);
|
||||
expect(isKnownTimezone('Europe/Atlantide')).toBe(false);
|
||||
expect(isKnownTimezone('')).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user