dorfteich/apps/api/src/setup/setup.e2e.db.test.ts
Claude Fable 5 f0a82bad20
Some checks failed
CD / Build and push images (push) Successful in 3m16s
CI / Lint, typecheck, test (push) Successful in 3m5s
CD / Deploy to Test (push) Successful in 13s
CD / Smoke tests against Test (push) Failing after 3m35s
CD / Promote to Int (push) Has been skipped
CI / Auth e2e pack (push) Successful in 5m6s
CI / Import/export fidelity gate (push) Successful in 43s
CI / Build container images (push) Has been skipped
Add the first-run setup wizard API with env-backed secret store (#80)
When the api runs against a database without the setup.completedAt
marker, a global SetupGuard answers every non-exempt route with 503
setup_required; only /setup/*, health probes, and the session routes
stay reachable. The wizard steps (POST /setup/admin|instance|smtp|
registration|complete) write straight to their production homes; the
Site Admin step signs its creator in, later steps require that session.
Completing sets the marker and locks every step permanently (410, also
across restarts, and not reopenable via PATCH /admin/settings).

SMTP entered in the wizard is verified with a live delivery test first
(failure blocks the step with the transport error as detail) and then
persisted to the new env-backed secret store: a mode-600 dotenv file on
the new `secrets` volume (SECRETS_FILE). Explicit container env always
wins over the store; empty compose-passed strings count as unset. The
mail transport now resolves lazily through SmtpConfigService so wizard
changes apply without a restart.

SETUP_ADMIN_* env pre-seeds the whole wizard at boot for automated
deploys; a backfill migration marks instances that already have a Site
Admin as completed, and seed/vitest global-setup do the same for
fixture databases. The setup e2e suite provisions its own fresh
database (CREATE DATABASE + migrate deploy) per run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-11 15:10:28 +02:00

386 lines
14 KiB
TypeScript

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<string> {
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<void> {
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 <wiki@example.org>',
})
.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 <wiki@example.org>',
})
.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<void>;
}
/**
* 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<FakeSmtpServer> {
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<void>((done) => {
server.close(() => done());
}),
});
});
});
}