Jusqu'ici personne ne pouvait entrer dans l'application : le modèle Invitation existait en base mais aucun code ne s'en servait, et le seul moyen d'obtenir un compte était le jeu de données de démonstration. Le lien vaut autant qu'un mot de passe le temps de sa validité, d'où trois règles : sept jours, un seul usage, et une révocation possible sans attendre l'expiration. Seule l'empreinte du jeton est conservée — renvoyer une invitation émet donc un nouveau lien et invalide le précédent, ce qui est aussi la bonne réponse à « il a perdu le message ». Deux liens vivants pour un même accès, ce sont deux portes dont une seule est tracée comme ayant servi. Le compte est porté par le lien lui-même, préfixé au secret. La table est protégée par RLS, laquelle exige de connaître le compte avant toute lecture : sans ce préfixe il aurait fallu ouvrir la politique aux requêtes sans compte — c'est-à-dire la vider de son sens. Divulguer un identifiant opaque à qui est membre du compte ne coûte rien. Le lien est aussi rendu une fois, à l'écran de celui qui l'émet. Sans cela un déploiement neuf ne peut inviter personne : configurer le serveur d'envoi demande d'être connecté, et être connecté demande une invitation. Il n'est ni conservé ni journalisé. Deux cas à l'acceptation, un seul demande un mot de passe. Si aucun compte n'existe pour l'adresse, il est créé ; s'il en existe un, le salarié est rattaché sans qu'on touche à son mot de passe — détenir le lien prouve l'accès à la boîte, ce qui suffit à rattacher un accès mais ne justifie pas de réinitialiser l'authentification d'un compte existant. Pas de connexion automatique non plus : un mot de passe qu'on vient de choisir se fixe en s'en servant. `members.invite` est une capacité distincte de `members.edit` : ouvrir un accès n'est pas modifier un dossier, et tel client voudra confier l'un sans l'autre. Deux défauts trouvés par les tests plutôt qu'en production : le contrôle qui refuse un mot de passe contenant le nom du salarié laissait passer « riviere » pour « Rivière » faute de replier les accents — soit exactement la variante qu'on tape au clavier ; et les contextes « visiteur » des tests héritaient de la session du responsable, si bien que le parcours anonyme n'était pas éprouvé. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
157 lines
4.9 KiB
TypeScript
157 lines
4.9 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
|
||
import {
|
||
composeInvitationToken,
|
||
expiryFrom,
|
||
INVITATION_TTL_DAYS,
|
||
invitationState,
|
||
MIN_PASSWORD_LENGTH,
|
||
passwordProblem,
|
||
splitInvitationToken,
|
||
} from '@/domain/access/invitation';
|
||
|
||
const now = new Date('2026-03-10T09:00:00Z');
|
||
|
||
function invitation(overrides: Partial<{
|
||
expiresAt: Date;
|
||
acceptedAt: Date | null;
|
||
revokedAt: Date | null;
|
||
}> = {}) {
|
||
return {
|
||
expiresAt: new Date('2026-03-17T09:00:00Z'),
|
||
acceptedAt: null,
|
||
revokedAt: null,
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
describe('état d’une invitation', () => {
|
||
it('est en attente tant qu’elle n’a rien subi', () => {
|
||
expect(invitationState(invitation(), now)).toBe('PENDING');
|
||
});
|
||
|
||
it('expire à l’instant exact de son échéance', () => {
|
||
// La borne compte : une invitation « valable jusqu'au 17 » ne doit pas
|
||
// rester utilisable une seconde de plus.
|
||
const expiresAt = new Date('2026-03-10T09:00:00Z');
|
||
expect(invitationState(invitation({ expiresAt }), now)).toBe('EXPIRED');
|
||
expect(
|
||
invitationState(
|
||
invitation({ expiresAt: new Date('2026-03-10T09:00:01Z') }),
|
||
now,
|
||
),
|
||
).toBe('PENDING');
|
||
});
|
||
|
||
it('reste acceptée après son expiration', () => {
|
||
// Dire « expirée » d'un accès qui a servi tromperait celui qui relit le
|
||
// dossier : l'accès existe, la date d'échéance ne le concerne plus.
|
||
const state = invitationState(
|
||
invitation({
|
||
expiresAt: new Date('2026-01-01T00:00:00Z'),
|
||
acceptedAt: new Date('2025-12-30T00:00:00Z'),
|
||
}),
|
||
now,
|
||
);
|
||
expect(state).toBe('ACCEPTED');
|
||
});
|
||
|
||
it('distingue la révocation de l’expiration', () => {
|
||
expect(
|
||
invitationState(invitation({ revokedAt: new Date('2026-03-09T00:00:00Z') }), now),
|
||
).toBe('REVOKED');
|
||
});
|
||
|
||
it('donne la priorité à l’acceptation sur la révocation', () => {
|
||
// Révoquer après coup ne défait pas un accès déjà pris : c'est le
|
||
// membership qu'il faut archiver, et le dossier doit le montrer.
|
||
const state = invitationState(
|
||
invitation({
|
||
acceptedAt: new Date('2026-03-08T00:00:00Z'),
|
||
revokedAt: new Date('2026-03-09T00:00:00Z'),
|
||
}),
|
||
now,
|
||
);
|
||
expect(state).toBe('ACCEPTED');
|
||
});
|
||
});
|
||
|
||
describe('échéance', () => {
|
||
it('court sur la durée annoncée', () => {
|
||
const expiry = expiryFrom(now);
|
||
const days = (expiry.getTime() - now.getTime()) / 86_400_000;
|
||
expect(days).toBe(INVITATION_TTL_DAYS);
|
||
});
|
||
});
|
||
|
||
describe('jeton de lien', () => {
|
||
it('fait l’aller-retour', () => {
|
||
const token = composeInvitationToken('acc_123', 'sEcReT-value');
|
||
expect(splitInvitationToken(token)).toEqual({
|
||
accountId: 'acc_123',
|
||
secret: 'sEcReT-value',
|
||
});
|
||
});
|
||
|
||
it('coupe au premier point seulement', () => {
|
||
// Un secret contenant un point ne doit pas être tronqué : il partirait
|
||
// valide dans le message et serait refusé au retour.
|
||
expect(splitInvitationToken('acc.a.b.c')).toEqual({
|
||
accountId: 'acc',
|
||
secret: 'a.b.c',
|
||
});
|
||
});
|
||
|
||
it('refuse les formes dégénérées', () => {
|
||
expect(splitInvitationToken('sansPoint')).toBeNull();
|
||
expect(splitInvitationToken('.secret')).toBeNull();
|
||
expect(splitInvitationToken('compte.')).toBeNull();
|
||
expect(splitInvitationToken('')).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('mot de passe', () => {
|
||
const personal = {
|
||
firstName: 'Camille',
|
||
lastName: 'Rivière',
|
||
email: 'camille.riviere@example.fr',
|
||
};
|
||
|
||
it('accepte une phrase de passe', () => {
|
||
expect(passwordProblem('le vent se leve en mars', personal)).toBeNull();
|
||
});
|
||
|
||
it('refuse en dessous de la longueur minimale', () => {
|
||
const problem = passwordProblem('a'.repeat(MIN_PASSWORD_LENGTH - 1), {});
|
||
expect(problem).toContain(String(MIN_PASSWORD_LENGTH));
|
||
});
|
||
|
||
it('refuse un mot de passe contenant le nom', () => {
|
||
// Prénom et nom sont les premières combinaisons essayées, et ce sont
|
||
// précisément celles que l'invitation vient de divulguer.
|
||
expect(passwordProblem('Camille-2026-ok', personal)).toMatch(/nom/);
|
||
expect(passwordProblem('xx-riviere-xxxxxx', personal)).toMatch(/nom/);
|
||
});
|
||
|
||
it('refuse un mot de passe contenant l’adresse', () => {
|
||
expect(
|
||
passwordProblem('camille.riviere+1', personal),
|
||
).toMatch(/nom|adresse/);
|
||
});
|
||
|
||
it('ignore un nom trop court pour être discriminant', () => {
|
||
// Sans ce garde-fou, un salarié nommé « Li » ne pourrait employer aucun
|
||
// mot de passe contenant ces deux lettres.
|
||
expect(passwordProblem('lit de camp fleuri', { lastName: 'Li' })).toBeNull();
|
||
});
|
||
|
||
it('refuse une répétition du même caractère', () => {
|
||
expect(passwordProblem('aaaaaaaaaaaaaaaa', {})).toMatch(/répétition/);
|
||
});
|
||
|
||
it('borne la longueur haute', () => {
|
||
// Argon2 sur une saisie de plusieurs mégaoctets immobiliserait le serveur.
|
||
expect(passwordProblem('x'.repeat(201), {})).toMatch(/200/);
|
||
});
|
||
});
|