import { execFileSync } from 'node:child_process'; import { existsSync, mkdtempSync, statSync } from 'node:fs'; import * as net from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { INestApplication } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { readSecretsFile } from '../config/secret-store'; import { createTestApp, sessionCookieOf } from '../testing/test-app'; import { hasTestDb, uniqueSuffix } from '../testing/test-db'; import { SetupService } from './setup.service'; /** apps/api — the Prisma schema and the workspace-linked prisma CLI live here. */ const API_ROOT = join(__dirname, '..', '..'); /** * The wizard runs exactly once against an EMPTY database — the shared test * database is seeded/marked as completed, so this suite provisions its own * fresh database per run (CREATE DATABASE + `prisma migrate deploy`, which * also exercises the backfill migration on a virgin schema) and drops it * afterwards. Runs sequentially with the other files (fileParallelism off). */ describe.skipIf(!hasTestDb)('first-run setup wizard (fresh database, issue #80)', () => { const baseUrl = process.env.TEST_DATABASE_URL!; const baseSecretsFile = process.env.SECRETS_FILE; const suffix = uniqueSuffix(); function freshDatabaseUrl(name: string): string { const url = new URL(baseUrl); url.pathname = `/${name}`; return url.toString(); } async function createFreshDatabase(name: string): Promise { const admin = new PrismaClient({ datasourceUrl: baseUrl }); try { await admin.$executeRawUnsafe(`CREATE DATABASE "${name}"`); } finally { await admin.$disconnect(); } const url = freshDatabaseUrl(name); execFileSync( process.execPath, [join(API_ROOT, 'node_modules', 'prisma', 'build', 'index.js'), 'migrate', 'deploy'], { env: { ...process.env, DATABASE_URL: url }, stdio: 'pipe', cwd: API_ROOT }, ); return url; } async function dropDatabase(name: string): Promise { const admin = new PrismaClient({ datasourceUrl: baseUrl }); try { await admin.$executeRawUnsafe(`DROP DATABASE IF EXISTS "${name}" WITH (FORCE)`); } finally { await admin.$disconnect(); } } afterAll(() => { // Leave the worker env as found — later suites in this process must // keep hitting the shared test database. process.env.TEST_DATABASE_URL = baseUrl; process.env.DATABASE_URL = baseUrl; if (baseSecretsFile === undefined) delete process.env.SECRETS_FILE; else process.env.SECRETS_FILE = baseSecretsFile; }); describe('interactive wizard flow', () => { const dbName = `dorfteich_setup_${suffix}`; let app: INestApplication; let prisma: PrismaClient; let cookie: string; let secretsFile: string; const admin = { username: `setup-admin-${suffix}`, email: `setup-admin-${suffix}@example.org`, displayName: 'Setup Admin', password: 'ein wirklich gutes passwort', locale: 'de' as const, }; const api = () => request(app.getHttpServer()); beforeAll(async () => { const url = await createFreshDatabase(dbName); process.env.TEST_DATABASE_URL = url; secretsFile = join(mkdtempSync(join(tmpdir(), 'dorfteich-setup-')), 'secrets.env'); process.env.SECRETS_FILE = secretsFile; prisma = new PrismaClient({ datasourceUrl: url }); app = await createTestApp(); }, 60_000); afterAll(async () => { await prisma.$disconnect(); await app.close(); await dropDatabase(dbName); }); it('requires setup on a fresh database and gates every non-exempt route', async () => { const status = await api().get('/api/v1/setup').expect(200); expect(status.body).toMatchObject({ status: 'required', adminCreated: false, smtpConfigured: false, }); // Protected and public routes alike answer with the setup state … const ponds = await api().get('/api/v1/ponds').expect(503); expect(ponds.body.code).toBe('setup_required'); const signup = await api().post('/api/v1/auth/signup').send(admin).expect(503); expect(signup.body.code).toBe('setup_required'); // … while health stays reachable for deploys and monitors. await api().get('/api/v1/healthz').expect(200); }); it('creates the site admin verified, signed in, and with a personal pond', async () => { const res = await api().post('/api/v1/setup/admin').send(admin).expect(201); expect(res.body).toMatchObject({ username: admin.username, isSiteAdmin: true }); cookie = sessionCookieOf(res); const me = await api().get('/api/v1/auth/me').set('Cookie', cookie).expect(200); expect(me.body.isSiteAdmin).toBe(true); const user = await prisma.user.findUnique({ where: { username: admin.username } }); expect(user?.status).toBe('ACTIVE'); expect(user?.emailVerifiedAt).not.toBeNull(); const personal = await prisma.pond.count({ where: { ownerId: user!.id, type: 'PERSONAL' }, }); expect(personal).toBe(1); }); it('rejects a second admin and unauthenticated steps', async () => { const dup = await api() .post('/api/v1/setup/admin') .send({ ...admin, username: `other-${suffix}`, email: `other-${suffix}@example.org` }) .expect(409); expect(dup.body.code).toBe('setup_admin_exists'); await api() .post('/api/v1/setup/instance') .send({ name: 'Testteich', defaultLocale: 'de' }) .expect(401); }); it('applies instance name, locale, and registration mode', async () => { await api() .post('/api/v1/setup/instance') .set('Cookie', cookie) .send({ name: 'Testteich', defaultLocale: 'de' }) .expect(204); await api() .post('/api/v1/setup/registration') .set('Cookie', cookie) .send({ mode: 'closed' }) .expect(204); }); it('blocks the SMTP step with actionable detail when the live test fails', async () => { const res = await api() .post('/api/v1/setup/smtp') .set('Cookie', cookie) .send({ // Nothing listens on port 9 — the connection is refused fast. host: '127.0.0.1', port: 9, secure: false, from: 'Testteich ', }) .expect(400); expect(res.body.code).toBe('smtp_test_failed'); expect(res.body.details?.smtp?.[0]).toBeTruthy(); // Nothing was persisted for the failed attempt. expect(existsSync(secretsFile)).toBe(false); }); it('persists SMTP to the secret store after a successful live test', async () => { const smtp = await startFakeSmtpServer(); try { await api() .post('/api/v1/setup/smtp') .set('Cookie', cookie) .send({ host: '127.0.0.1', port: smtp.port, secure: false, from: 'Testteich ', }) .expect(204); } finally { await smtp.close(); } // The live test really delivered a message to the admin address. expect(smtp.messages.length).toBe(1); expect(smtp.messages[0]).toContain(admin.email); const stored = readSecretsFile(secretsFile); expect(stored.SMTP_HOST).toBe('127.0.0.1'); expect(stored.SMTP_PORT).toBe(String(smtp.port)); expect(statSync(secretsFile).mode & 0o777).toBe(0o600); }); it('completes the wizard, unlocking the app and locking every step (410)', async () => { await api().post('/api/v1/setup/complete').set('Cookie', cookie).expect(204); // The instance works: gate lifted, settings took effect. await api().get('/api/v1/ponds').set('Cookie', cookie).expect(200); const registration = await api().get('/api/v1/auth/registration').expect(200); expect(registration.body.mode).toBe('closed'); const settings = await api().get('/api/v1/admin/settings').set('Cookie', cookie).expect(200); expect(settings.body['instance.name']).toBe('Testteich'); expect(settings.body['instance.defaultLocale']).toBe('de'); expect(settings.body['setup.completedAt']).toBeTruthy(); // Every wizard step is gone for good; the status stays readable. for (const [path, body] of [ ['admin', admin], ['instance', { name: 'X', defaultLocale: 'en' }], ['registration', { mode: 'open' }], ['complete', {}], ] as const) { const res = await api() .post(`/api/v1/setup/${path}`) .set('Cookie', cookie) .send(body) .expect(410); expect(res.body.code).toBe('setup_locked'); } const status = await api().get('/api/v1/setup').expect(200); expect(status.body.status).toBe('completed'); }); it('keeps the lock after a restart (fresh application instance)', async () => { const restarted = await createTestApp(); try { const res = await request(restarted.getHttpServer()) .post('/api/v1/setup/admin') .send({ ...admin, username: `late-${suffix}`, email: `late-${suffix}@example.org` }) .expect(410); expect(res.body.code).toBe('setup_locked'); await request(restarted.getHttpServer()).get('/api/v1/setup').expect(200); } finally { await restarted.close(); } }); it('refuses to reopen the lock through the admin settings endpoint', async () => { await api() .patch('/api/v1/admin/settings') .set('Cookie', cookie) .send({ 'setup.completedAt': null }) .expect(400); }); }); describe('env pre-seeding (automated deploys)', () => { const dbName = `dorfteich_preseed_${suffix}`; let app: INestApplication; const preseedEnv = { SETUP_ADMIN_USERNAME: `preseed-admin-${suffix}`, SETUP_ADMIN_EMAIL: `preseed-admin-${suffix}@example.org`, SETUP_ADMIN_PASSWORD: 'ein wirklich gutes passwort', SETUP_INSTANCE_NAME: 'Vorbefüllter Teich', SETUP_DEFAULT_LOCALE: 'de', SETUP_REGISTRATION_MODE: 'closed', } as const; beforeAll(async () => { const url = await createFreshDatabase(dbName); process.env.TEST_DATABASE_URL = url; process.env.SECRETS_FILE = join( mkdtempSync(join(tmpdir(), 'dorfteich-preseed-')), 'secrets.env', ); Object.assign(process.env, preseedEnv); app = await createTestApp(); }, 60_000); afterAll(async () => { for (const key of Object.keys(preseedEnv)) delete process.env[key]; await app.close(); await dropDatabase(dbName); }); it('completes and locks the wizard at boot without any interaction', async () => { // Boot hook is inert under NODE_ENV=test (like the other workers) — // drive the same method the hook runs. await app.get(SetupService).preseedFromEnv(); const api = () => request(app.getHttpServer()); const status = await api().get('/api/v1/setup').expect(200); expect(status.body.status).toBe('completed'); // The pre-seeded admin can sign in and use the instance right away. const login = await api() .post('/api/v1/auth/login') .send({ usernameOrEmail: preseedEnv.SETUP_ADMIN_USERNAME, password: preseedEnv.SETUP_ADMIN_PASSWORD, }) .expect(200); expect(login.body.isSiteAdmin).toBe(true); const cookie = sessionCookieOf(login); const settings = await api().get('/api/v1/admin/settings').set('Cookie', cookie).expect(200); expect(settings.body['instance.name']).toBe('Vorbefüllter Teich'); expect(settings.body['auth.registrationMode']).toBe('closed'); // A second boot-time pre-seed run is a no-op, and the wizard is locked. await app.get(SetupService).preseedFromEnv(); const locked = await api().post('/api/v1/setup/complete').set('Cookie', cookie).expect(410); expect(locked.body.code).toBe('setup_locked'); }); }); }); interface FakeSmtpServer { port: number; messages: string[]; close(): Promise; } /** * Minimal SMTP endpoint — just enough protocol for nodemailer's verify() * (connect + EHLO) and a plain unauthenticated send, so the wizard's live * delivery test runs against a real socket. */ function startFakeSmtpServer(): Promise { const messages: string[] = []; const server = net.createServer((socket) => { let buffer = ''; let inData = false; let current = ''; socket.write('220 fake.test ESMTP\r\n'); socket.on('data', (chunk) => { buffer += chunk.toString('utf8'); let newline: number; while ((newline = buffer.indexOf('\r\n')) >= 0) { const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 2); if (inData) { if (line === '.') { messages.push(current); current = ''; inData = false; socket.write('250 OK\r\n'); } else { current += line + '\n'; } continue; } const command = line.toUpperCase(); if (command.startsWith('EHLO') || command.startsWith('HELO')) { socket.write('250-fake.test\r\n250 8BITMIME\r\n'); } else if (command.startsWith('DATA')) { inData = true; socket.write('354 go ahead\r\n'); } else if (command.startsWith('QUIT')) { socket.write('221 bye\r\n'); socket.end(); } else { socket.write('250 OK\r\n'); } } }); }); return new Promise((resolve) => { server.listen(0, '127.0.0.1', () => { const port = (server.address() as net.AddressInfo).port; resolve({ port, messages, close: () => new Promise((done) => { server.close(() => done()); }), }); }); }); }