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) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 09:12:04 +02:00
1 parent a654a1a158
commit ab45d6e24e
2 files changed
+143 -29

No files matched your search

+58 -29
View File
@@ -16,18 +16,28 @@ function generateRandomPassword(): string {
} }
// Only printed the moment the account is actually created (first deploy on // 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 // have changed the password via /profile, so repeating it would just be
// stale and would leak credentials into logs indefinitely for no reason. // 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 url = process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000';
const line = '='.repeat(60); const line = '='.repeat(60);
console.log(line); log(line);
console.log('Seed: Admin-Zugang eingerichtet.'); log('Seed: Admin-Zugang eingerichtet.');
console.log(` URL: ${url}`); log(` URL: ${url}`);
console.log(` Benutzer: ${email}`); log(` Benutzer: ${email}`);
console.log(` Passwort: ${password}`); log(
console.log(line); password === null
? ' Passwort: wie in SEED_ADMIN_PASSWORD gesetzt'
: ` Passwort: ${password}`,
);
log(line);
} }
// Duplicated from src/task-templates/recurrence.ts rather than imported: // Duplicated from src/task-templates/recurrence.ts rather than imported:
@@ -189,22 +199,26 @@ async function seedDefaultTaskTemplates(prisma: PrismaClient): Promise<void> {
); );
} }
async function main(): Promise<void> { type AdminSeedClient = Pick<PrismaClient, 'user' | 'role'>;
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
await seedDefaultTaskTemplates(prisma); /**
* Creates the first administrator, but only while the user table is still
const email = process.env.SEED_ADMIN_EMAIL ?? DEFAULT_ADMIN_EMAIL; * empty. Checking SEED_ADMIN_EMAIL instead would add a second ADMIN (and log
const password = process.env.SEED_ADMIN_PASSWORD ?? generateRandomPassword(); * its password) on every start after the real admin changed their address.
const existing = await prisma.user.findUnique({ where: { email } }); */
if (existing) { export async function seedAdmin(
console.log(`Seed: admin user ${email} already exists, skipping.`); prisma: AdminSeedClient,
await prisma.$disconnect(); env: NodeJS.ProcessEnv,
return; 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 passwordHash = await bcrypt.hash(password, BCRYPT_SALT_ROUNDS);
const adminRole = await prisma.role.findUniqueOrThrow({ const adminRole = await prisma.role.findUniqueOrThrow({
where: { key: 'ADMIN' }, where: { key: 'ADMIN' },
@@ -218,27 +232,42 @@ async function main(): Promise<void> {
roleId: adminRole.id, roleId: adminRole.id,
}, },
}); });
printLoginBanner(email, password);
} catch (error: unknown) { } catch (error: unknown) {
// P2002 = unique constraint violation on email: another replica's // P2002 = unique constraint violation on email: another replica's
// startup won this race between the existence check above and this // startup won this race between the count above and this create()
// create() call. The user now exists either way, so this is a no-op, // call. The user now exists either way, so this is a no-op, not a
// not a failure - container startup must not crash on it. // failure - container startup must not crash on it.
if ( if (
error instanceof Prisma.PrismaClientKnownRequestError && error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002' error.code === 'P2002'
) { ) {
console.log( log(`Seed: admin user ${email} was created concurrently, skipping.`);
`Seed: admin user ${email} was created concurrently, skipping.`, return 'skipped';
); }
} else {
throw error; throw error;
} }
printLoginBanner(
email,
suppliedPassword === undefined ? password : null,
log,
);
return 'created';
} }
async function main(): Promise<void> {
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(); await prisma.$disconnect();
} }
// Only when run as `node dist/seed.js`, not when a test imports seedAdmin.
if (require.main === module) {
main().catch((error: unknown) => { main().catch((error: unknown) => {
console.error(error); console.error(error);
process.exit(1); process.exit(1);
}); });
}
+85
View File
@@ -0,0 +1,85 @@
import { Prisma } from '../generated/prisma';
import { seedAdmin } from '../prisma/seed';
type SeedClient = Parameters<typeof seedAdmin>[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: '[email protected]' },
(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: '[email protected]',
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: '[email protected]',
SEED_ADMIN_PASSWORD: 'geheim-vom-betrieb',
},
(line) => lines.push(line),
);
const output = lines.join('\n');
expect(output).toContain('[email protected]');
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/);
});
});