Critère d'acceptation de WP-01 resté sans écran : « un rôle personnalisé créé par un client modifie effectivement l'accès, sans changement de code ». Le catalogue de capacités existait, l'autorisation s'y adossait déjà, mais seul le semis pouvait attribuer quoi que ce soit. Deux dangers opposés, et il faut se protéger des deux. L'escalade d'abord : on ne peut accorder qu'une capacité qu'on détient soi-même, sans quoi la première personne autorisée à éditer un rôle s'accorde l'accès aux rémunérations en trois clics et le catalogue ne sert plus à rien. Le retrait, lui, reste permis même sur une capacité qu'on n'a pas — réduire un droit n'a jamais élargi le sien, et l'interdire empêcherait de corriger un rôle trop large. Le verrouillage ensuite : au moins un rôle doit conserver la gestion des droits. La condition se juge sur l'ensemble des rôles après coup, non sur celui qu'on édite — ce qui compte n'est pas que ce rôle-ci garde la capacité, mais qu'un rôle la garde. Sans cela une organisation se fermerait dehors, et le seul recours serait une intervention en base. Un rôle naît sans aucune capacité : en hériter de celles de son créateur distribuerait des droits que personne n'a demandés. Le niveau propriétaire se délègue à part. Un rôle système ne se supprime pas, un rôle attribué non plus — ses membres se retrouveraient dehors sans que personne l'ait décidé pour eux. Le test va jusqu'au bout : créer le rôle, l'attribuer, se connecter, constater qu'un écran s'ouvre et qu'un autre reste fermé. S'arrêter à « la case est cochée » n'aurait rien dit du critère. Trois défauts d'isolation des tests corrigés au passage, tous du même genre — un état qui s'accumule d'une exécution à l'autre : - la file des congés désignait « la première ligne de ce salarié » pour son nettoyage, et tombait sur une ligne laissée par un passage antérieur ; - l'écran de conservation tronquait à quarante pièces triées par ancienneté, faisant disparaître les pièces échues derrière celles dont il n'y a rien à dire — corrigé dans le produit, pas seulement dans le test ; - le test de verrouillage de période consommait un mois par exécution sans jamais le rendre. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
93 lines
3.4 KiB
TypeScript
93 lines
3.4 KiB
TypeScript
import { PrismaPg } from '@prisma/adapter-pg';
|
|
import { PrismaClient } from '@prisma/client';
|
|
|
|
/**
|
|
* Accès direct à la base, pour la mise en place des tests seulement.
|
|
*
|
|
* Certains états ne se posent pas par l'interface — retirer un second facteur
|
|
* dont on a perdu le secret, par exemple. Les fabriquer ici garde la suite
|
|
* rejouable sans ajouter au produit une porte qui n'aurait pas lieu d'exister.
|
|
*/
|
|
let client: PrismaClient | null = null;
|
|
|
|
function db(): PrismaClient {
|
|
// Prisma 7 exige un adaptateur : le client applicatif n'est pas réutilisable
|
|
// ici, il vit derrière `server-only`.
|
|
client ??= new PrismaClient({
|
|
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
|
|
});
|
|
return client;
|
|
}
|
|
|
|
/** Remet un compte à l'état « aucun second facteur », sans effet s'il n'en a pas. */
|
|
export async function resetMfa(email: string): Promise<void> {
|
|
const user = await db().user.findUnique({
|
|
where: { email },
|
|
select: { id: true },
|
|
});
|
|
if (!user) return;
|
|
|
|
await db().user.update({
|
|
where: { id: user.id },
|
|
data: { mfaSecretEnc: null, mfaEnrolledAt: null, mfaLastStep: null },
|
|
});
|
|
await db().mfaRecoveryCode.deleteMany({ where: { userId: user.id } });
|
|
}
|
|
|
|
/**
|
|
* Recule la date de dépôt d'une pièce.
|
|
*
|
|
* Aucune interface ne permet d'antidater, et c'est bien ainsi. Sans ce levier,
|
|
* la moitié utile de la purge — celle qui efface — resterait invérifiable :
|
|
* seule l'échéance atteinte la déclenche, et elle se compte en mois.
|
|
*
|
|
* La transaction pose `app.account_id` : la table est protégée par RLS, et une
|
|
* mise à jour sans compte courant ne toucherait aucune ligne — en silence.
|
|
*/
|
|
export async function backdateDocument(
|
|
name: string,
|
|
months: number,
|
|
): Promise<void> {
|
|
const document = await db().$queryRaw<Array<{ id: string; accountId: string }>>`
|
|
SELECT id, "accountId" FROM "Document" WHERE name = ${name} LIMIT 1
|
|
`;
|
|
const found = document[0];
|
|
if (!found) throw new Error(`Pièce introuvable : ${name}`);
|
|
|
|
const uploadedAt = new Date();
|
|
uploadedAt.setMonth(uploadedAt.getMonth() - months);
|
|
|
|
await db().$transaction(async (tx) => {
|
|
await tx.$executeRaw`SELECT set_config('app.account_id', ${found.accountId}, true)`;
|
|
await tx.$executeRaw`UPDATE "Document" SET "uploadedAt" = ${uploadedAt} WHERE id = ${found.id}`;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Attribue un rôle à un membership, par son libellé.
|
|
*
|
|
* Aucun écran d'affectation n'existe encore : sans ce levier, l'effet d'un rôle
|
|
* personnalisé — le critère d'acceptation de WP-01 — resterait invérifiable.
|
|
*/
|
|
export async function assignRole(
|
|
membershipId: string,
|
|
roleName: string,
|
|
): Promise<void> {
|
|
const rows = await db().$queryRaw<Array<{ id: string; accountId: string }>>`
|
|
SELECT id, "accountId" FROM "Role" WHERE name = ${roleName} LIMIT 1
|
|
`;
|
|
const role = rows[0];
|
|
if (!role) throw new Error(`Rôle introuvable : ${roleName}`);
|
|
|
|
const updated = await db().$transaction(async (tx) => {
|
|
await tx.$executeRaw`SELECT set_config('app.account_id', ${role.accountId}, true)`;
|
|
return tx.$executeRaw`UPDATE "Membership" SET "roleId" = ${role.id} WHERE id = ${membershipId}`;
|
|
});
|
|
|
|
// Une mise à jour qui ne touche aucune ligne est indiscernable d'un succès :
|
|
// le test échouerait bien plus loin, sur un refus d'accès inexpliqué.
|
|
if (updated === 0) {
|
|
throw new Error(`Aucun membership mis à jour : ${membershipId}`);
|
|
}
|
|
}
|