Merge pull request #25 from R0m1k3/claude/combohr-app-recreation-plan-yu7wre
Rendre l'image constructible sans les secrets de production
This commit is contained in:
4 files changed
+133
-24
No files matched your search
@@ -0,0 +1,24 @@
|
||||
# Contexte de construction.
|
||||
#
|
||||
# `.env` en tête et ce n'est pas un détail : il porte la clé de chiffrement et
|
||||
# les mots de passe de la base. Copié dans le contexte, il finirait dans une
|
||||
# couche de l'image, lisible par quiconque la récupère.
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
node_modules
|
||||
.next
|
||||
.pgdata
|
||||
storage
|
||||
test-results
|
||||
playwright-report
|
||||
.git
|
||||
.github
|
||||
.claude
|
||||
|
||||
# Documents de travail : ils alourdissent le contexte sans servir au build.
|
||||
Audit Combo
|
||||
design
|
||||
*.md
|
||||
!README.md
|
||||
+32
-1
@@ -51,4 +51,35 @@ function load(): Env {
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
export const env = load();
|
||||
let cached: Env | null = null;
|
||||
|
||||
/**
|
||||
* Contrat d'environnement, validé à la **première lecture** et non à l'import.
|
||||
*
|
||||
* La différence n'est pas cosmétique : Next.js évalue les modules serveur
|
||||
* pendant la construction de l'image, où ni la base ni la clé de chiffrement
|
||||
* n'existent — ce sont des valeurs d'exécution. Valider à l'import rendait
|
||||
* l'image impossible à construire sans les secrets de production, c'est-à-dire
|
||||
* exigeait de les confier au constructeur.
|
||||
*
|
||||
* La garantie reste entière : la première lecture arrive au premier appel utile,
|
||||
* bien avant qu'une requête aboutisse, et échoue avec le même message.
|
||||
*/
|
||||
export const env: Env = new Proxy({} as Env, {
|
||||
get(_target, property) {
|
||||
cached ??= load();
|
||||
return cached[property as keyof Env];
|
||||
},
|
||||
has(_target, property) {
|
||||
cached ??= load();
|
||||
return property in cached;
|
||||
},
|
||||
ownKeys() {
|
||||
cached ??= load();
|
||||
return Reflect.ownKeys(cached);
|
||||
},
|
||||
getOwnPropertyDescriptor(_target, property) {
|
||||
cached ??= load();
|
||||
return Reflect.getOwnPropertyDescriptor(cached, property);
|
||||
},
|
||||
});
|
||||
+24
-4
@@ -24,8 +24,28 @@ function createClient(): PrismaClient {
|
||||
});
|
||||
}
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? createClient();
|
||||
|
||||
if (env.NODE_ENV !== 'production') {
|
||||
globalForPrisma.prisma = prisma;
|
||||
/**
|
||||
* Client construit à la **première utilisation**, pas à l'import.
|
||||
*
|
||||
* Next.js évalue les modules serveur pendant la construction de l'image, où
|
||||
* aucune base n'existe : ouvrir la connexion à l'import rendait l'image
|
||||
* impossible à construire sans une base joignable et sans les secrets de
|
||||
* production. Une connexion est un objet d'exécution.
|
||||
*/
|
||||
function client(): PrismaClient {
|
||||
globalForPrisma.prisma ??= createClient();
|
||||
return globalForPrisma.prisma;
|
||||
}
|
||||
|
||||
export const prisma: PrismaClient = new Proxy({} as PrismaClient, {
|
||||
get(_target, property) {
|
||||
const instance = client();
|
||||
const value = Reflect.get(instance, property) as unknown;
|
||||
// Les méthodes doivent rester liées à leur client : détachées du proxy,
|
||||
// elles perdraient leur `this`.
|
||||
return typeof value === 'function' ? value.bind(instance) : value;
|
||||
},
|
||||
has(_target, property) {
|
||||
return property in client();
|
||||
},
|
||||
});
|
||||
+53
-19
@@ -1,15 +1,23 @@
|
||||
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.
|
||||
* Le module de configuration valide à la **première lecture**, pas à l'import :
|
||||
* Next.js évalue les modules serveur pendant la construction de l'image, où ni
|
||||
* la base ni la clé n'existent.
|
||||
*
|
||||
* Les valeurs doivent donc rester en place pendant la lecture — les restaurer
|
||||
* avant de lire ne prouverait rien, sinon que le module n'a rien lu.
|
||||
*/
|
||||
async function loadEnv(values: Record<string, string | undefined>) {
|
||||
async function withEnv<T>(
|
||||
values: Record<string, string | undefined>,
|
||||
read: (env: typeof import('@/lib/env').env) => T,
|
||||
): Promise<T> {
|
||||
const previous = { ...process.env };
|
||||
process.env = { ...previous, ...values } as NodeJS.ProcessEnv;
|
||||
try {
|
||||
vi.resetModules();
|
||||
return await import('@/lib/env');
|
||||
const { env } = await import('@/lib/env');
|
||||
return read(env);
|
||||
} finally {
|
||||
process.env = previous;
|
||||
}
|
||||
@@ -19,31 +27,57 @@ 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',
|
||||
});
|
||||
const values = await withEnv(
|
||||
{
|
||||
DATABASE_URL: 'postgresql://user:pass@localhost:5432/planflow',
|
||||
ENCRYPTION_KEY: VALID_KEY,
|
||||
APP_URL: 'https://planflow.example',
|
||||
},
|
||||
(env) => ({ database: env.DATABASE_URL, app: env.APP_URL }),
|
||||
);
|
||||
|
||||
expect(env.DATABASE_URL).toContain('planflow');
|
||||
expect(env.APP_URL).toBe('https://planflow.example');
|
||||
expect(values.database).toContain('planflow');
|
||||
expect(values.app).toBe('https://planflow.example');
|
||||
});
|
||||
|
||||
it('n’exige rien tant qu’aucune valeur n’est lue', async () => {
|
||||
// C'est ce qui rend l'image constructible sans les secrets de production :
|
||||
// importer le module ne doit pas suffire à le faire échouer.
|
||||
await expect(
|
||||
withEnv({ DATABASE_URL: undefined, ENCRYPTION_KEY: undefined }, () => 'ok'),
|
||||
).resolves.toBe('ok');
|
||||
});
|
||||
|
||||
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'),
|
||||
}),
|
||||
withEnv(
|
||||
{
|
||||
DATABASE_URL: 'postgresql://user:pass@localhost:5432/planflow',
|
||||
ENCRYPTION_KEY: Buffer.alloc(16, 1).toString('base64'),
|
||||
},
|
||||
(env) => env.ENCRYPTION_KEY,
|
||||
),
|
||||
).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,
|
||||
}),
|
||||
withEnv(
|
||||
{
|
||||
DATABASE_URL: 'mysql://user:pass@localhost:3306/planflow',
|
||||
ENCRYPTION_KEY: VALID_KEY,
|
||||
},
|
||||
(env) => env.DATABASE_URL,
|
||||
),
|
||||
).rejects.toThrow(/DATABASE_URL/);
|
||||
});
|
||||
|
||||
it('refuse une configuration absente, à la lecture', async () => {
|
||||
await expect(
|
||||
withEnv(
|
||||
{ DATABASE_URL: undefined, ENCRYPTION_KEY: undefined },
|
||||
(env) => env.DATABASE_URL,
|
||||
),
|
||||
).rejects.toThrow(/DATABASE_URL/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user