From ab45d6e24e10b66360a38b3e00d0caf18f6cc40a Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Fri, 16 Oct 2026 08:19:33 +0200 Subject: [PATCH] fix: seed creates the first admin only on an empty user table The seed checked only SEED_ADMIN_EMAIL. After the real admin changed their address, every start created a second ADMIN with a random password and wrote that password to the log. The seed now skips as soon as any user exists and never prints an operator-supplied password. seedAdmin() is exported for tests; main() runs only when the file is executed directly. Refs #45 Co-Authored-By: Claude Opus 5.5 (1M context) --- ApiCore-Backend/prisma/seed.ts | 97 +++++++++++++++++--------- ApiCore-Backend/src/seed-admin.spec.ts | 85 ++++++++++++++++++++++ 2 files changed, 148 insertions(+), 34 deletions(-) create mode 100644 ApiCore-Backend/src/seed-admin.spec.ts diff --git a/ApiCore-Backend/prisma/seed.ts b/ApiCore-Backend/prisma/seed.ts index 29a4d51..be1c02f 100644 --- a/ApiCore-Backend/prisma/seed.ts +++ b/ApiCore-Backend/prisma/seed.ts @@ -16,18 +16,28 @@ function generateRandomPassword(): string { } // Only printed the moment the account is actually created (first deploy on -// an empty database), never on later restarts: by then the real admin may +// an empty user table), never on later restarts: by then the real admin may // have changed the password via /profile, so repeating it would just be // stale and would leak credentials into logs indefinitely for no reason. -function printLoginBanner(email: string, password: string): void { +// An operator-supplied password is never printed: the operator already has +// it, and the log would only be one more place it leaks to. +function printLoginBanner( + email: string, + password: string | null, + log: (line: string) => void, +): void { const url = process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000'; const line = '='.repeat(60); - console.log(line); - console.log('Seed: Admin-Zugang eingerichtet.'); - console.log(` URL: ${url}`); - console.log(` Benutzer: ${email}`); - console.log(` Passwort: ${password}`); - console.log(line); + log(line); + log('Seed: Admin-Zugang eingerichtet.'); + log(` URL: ${url}`); + log(` Benutzer: ${email}`); + log( + password === null + ? ' Passwort: wie in SEED_ADMIN_PASSWORD gesetzt' + : ` Passwort: ${password}`, + ); + log(line); } // Duplicated from src/task-templates/recurrence.ts rather than imported: @@ -189,22 +199,26 @@ async function seedDefaultTaskTemplates(prisma: PrismaClient): Promise { ); } -async function main(): Promise { - const prisma = new PrismaClient({ - adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }), - }); +type AdminSeedClient = Pick; - await seedDefaultTaskTemplates(prisma); - - const email = process.env.SEED_ADMIN_EMAIL ?? DEFAULT_ADMIN_EMAIL; - const password = process.env.SEED_ADMIN_PASSWORD ?? generateRandomPassword(); - const existing = await prisma.user.findUnique({ where: { email } }); - if (existing) { - console.log(`Seed: admin user ${email} already exists, skipping.`); - await prisma.$disconnect(); - return; +/** + * Creates the first administrator, but only while the user table is still + * empty. Checking SEED_ADMIN_EMAIL instead would add a second ADMIN (and log + * its password) on every start after the real admin changed their address. + */ +export async function seedAdmin( + prisma: AdminSeedClient, + env: NodeJS.ProcessEnv, + log: (line: string) => void, +): Promise<'created' | 'skipped'> { + if ((await prisma.user.count()) > 0) { + log('Seed: Es gibt bereits Benutzer, kein Admin angelegt.'); + return 'skipped'; } + const email = env.SEED_ADMIN_EMAIL ?? DEFAULT_ADMIN_EMAIL; + const suppliedPassword = env.SEED_ADMIN_PASSWORD; + const password = suppliedPassword ?? generateRandomPassword(); const passwordHash = await bcrypt.hash(password, BCRYPT_SALT_ROUNDS); const adminRole = await prisma.role.findUniqueOrThrow({ where: { key: 'ADMIN' }, @@ -218,27 +232,42 @@ async function main(): Promise { roleId: adminRole.id, }, }); - printLoginBanner(email, password); } catch (error: unknown) { // P2002 = unique constraint violation on email: another replica's - // startup won this race between the existence check above and this - // create() call. The user now exists either way, so this is a no-op, - // not a failure - container startup must not crash on it. + // startup won this race between the count above and this create() + // call. The user now exists either way, so this is a no-op, not a + // failure - container startup must not crash on it. if ( error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002' ) { - console.log( - `Seed: admin user ${email} was created concurrently, skipping.`, - ); - } else { - throw error; + log(`Seed: admin user ${email} was created concurrently, skipping.`); + return 'skipped'; } + throw error; } + printLoginBanner( + email, + suppliedPassword === undefined ? password : null, + log, + ); + return 'created'; +} + +async function main(): Promise { + const prisma = new PrismaClient({ + adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }), + }); + + await seedDefaultTaskTemplates(prisma); + await seedAdmin(prisma, process.env, (line) => console.log(line)); await prisma.$disconnect(); } -main().catch((error: unknown) => { - console.error(error); - process.exit(1); -}); +// Only when run as `node dist/seed.js`, not when a test imports seedAdmin. +if (require.main === module) { + main().catch((error: unknown) => { + console.error(error); + process.exit(1); + }); +} diff --git a/ApiCore-Backend/src/seed-admin.spec.ts b/ApiCore-Backend/src/seed-admin.spec.ts new file mode 100644 index 0000000..7dc64ac --- /dev/null +++ b/ApiCore-Backend/src/seed-admin.spec.ts @@ -0,0 +1,85 @@ +import { Prisma } from '../generated/prisma'; +import { seedAdmin } from '../prisma/seed'; + +type SeedClient = Parameters[0]; + +function fakeClient( + userCount: number, + create = jest.fn().mockResolvedValue({}), +) { + const client = { + user: { count: jest.fn().mockResolvedValue(userCount), create }, + role: { + findUniqueOrThrow: jest.fn().mockResolvedValue({ id: 'role-admin' }), + }, + }; + return { client, prisma: client as unknown as SeedClient }; +} + +describe('seedAdmin (prisma/seed.ts)', () => { + it('creates no admin once any user exists, even when SEED_ADMIN_EMAIL is unknown', async () => { + const { client, prisma } = fakeClient(1); + const lines: string[] = []; + + const result = await seedAdmin( + prisma, + { SEED_ADMIN_EMAIL: 'admin@apicore.local' }, + (line) => lines.push(line), + ); + + expect(result).toBe('skipped'); + expect(client.user.create).not.toHaveBeenCalled(); + expect(lines.join('\n')).not.toMatch(/Passwort/); + }); + + it('creates the admin on an empty user table and prints a generated password once', async () => { + const { client, prisma } = fakeClient(0); + const lines: string[] = []; + + const result = await seedAdmin(prisma, {}, (line) => lines.push(line)); + + expect(result).toBe('created'); + const [args] = client.user.create.mock.calls[0] as [ + { data: { email: string; roleId: string } }, + ]; + expect(args.data).toMatchObject({ + email: 'admin@apicore.local', + roleId: 'role-admin', + }); + const passwordLine = lines.find((line) => line.includes('Passwort:')); + expect(passwordLine).toMatch(/Passwort: +\S{20,}/); + }); + + it('never prints an operator-supplied password', async () => { + const { prisma } = fakeClient(0); + const lines: string[] = []; + + await seedAdmin( + prisma, + { + SEED_ADMIN_EMAIL: 'chef@example.org', + SEED_ADMIN_PASSWORD: 'geheim-vom-betrieb', + }, + (line) => lines.push(line), + ); + + const output = lines.join('\n'); + expect(output).toContain('chef@example.org'); + expect(output).not.toContain('geheim-vom-betrieb'); + expect(output).toContain('SEED_ADMIN_PASSWORD'); + }); + + it('treats a concurrent create by another replica as done', async () => { + const conflict = new Prisma.PrismaClientKnownRequestError('unique', { + code: 'P2002', + clientVersion: 'test', + }); + const { prisma } = fakeClient(0, jest.fn().mockRejectedValue(conflict)); + const lines: string[] = []; + + const result = await seedAdmin(prisma, {}, (line) => lines.push(line)); + + expect(result).toBe('skipped'); + expect(lines.join('\n')).not.toMatch(/Passwort/); + }); +});