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;
|
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();
|
/**
|
||||||
|
* Client construit à la **première utilisation**, pas à l'import.
|
||||||
if (env.NODE_ENV !== 'production') {
|
*
|
||||||
globalForPrisma.prisma = prisma;
|
* 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';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The env module validates at import, so each case needs a fresh module
|
* Le module de configuration valide à la **première lecture**, pas à l'import :
|
||||||
* registry with process.env set beforehand.
|
* 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 };
|
const previous = { ...process.env };
|
||||||
process.env = { ...previous, ...values } as NodeJS.ProcessEnv;
|
process.env = { ...previous, ...values } as NodeJS.ProcessEnv;
|
||||||
try {
|
try {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
return await import('@/lib/env');
|
const { env } = await import('@/lib/env');
|
||||||
|
return read(env);
|
||||||
} finally {
|
} finally {
|
||||||
process.env = previous;
|
process.env = previous;
|
||||||
}
|
}
|
||||||
@@ -19,31 +27,57 @@ const VALID_KEY = Buffer.alloc(32, 7).toString('base64');
|
|||||||
|
|
||||||
describe('configuration d’environnement', () => {
|
describe('configuration d’environnement', () => {
|
||||||
it('accepte une configuration complète', async () => {
|
it('accepte une configuration complète', async () => {
|
||||||
const { env } = await loadEnv({
|
const values = await withEnv(
|
||||||
DATABASE_URL: 'postgresql://user:pass@localhost:5432/planflow',
|
{
|
||||||
ENCRYPTION_KEY: VALID_KEY,
|
DATABASE_URL: 'postgresql://user:pass@localhost:5432/planflow',
|
||||||
APP_URL: 'https://planflow.example',
|
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(values.database).toContain('planflow');
|
||||||
expect(env.APP_URL).toBe('https://planflow.example');
|
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 () => {
|
it('refuse une clé de chiffrement qui ne fait pas 32 octets', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
loadEnv({
|
withEnv(
|
||||||
DATABASE_URL: 'postgresql://user:pass@localhost:5432/planflow',
|
{
|
||||||
ENCRYPTION_KEY: Buffer.alloc(16, 1).toString('base64'),
|
DATABASE_URL: 'postgresql://user:pass@localhost:5432/planflow',
|
||||||
}),
|
ENCRYPTION_KEY: Buffer.alloc(16, 1).toString('base64'),
|
||||||
|
},
|
||||||
|
(env) => env.ENCRYPTION_KEY,
|
||||||
|
),
|
||||||
).rejects.toThrow(/ENCRYPTION_KEY/);
|
).rejects.toThrow(/ENCRYPTION_KEY/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('refuse une URL de base non PostgreSQL', async () => {
|
it('refuse une URL de base non PostgreSQL', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
loadEnv({
|
withEnv(
|
||||||
DATABASE_URL: 'mysql://user:pass@localhost:3306/planflow',
|
{
|
||||||
ENCRYPTION_KEY: VALID_KEY,
|
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/);
|
).rejects.toThrow(/DATABASE_URL/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user