dorfteich/apps/api/src/setup/setup.e2e.db.test.ts
Claude Fable 5 20677ea247
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 7m58s
CI / Build container images (pull_request) Successful in 1m11s
CI / Auth e2e pack (pull_request) Successful in 9m12s
CI / Import/export fidelity gate (pull_request) Successful in 59s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
CD / Promote to Int (push) Blocked by required conditions
Self-hosting findings: URL-safe password advice, operator-readable pre-seed errors (#324, #325)
Two findings from Stefan's manual clean install per the guide, both
ending in an api restart loop that was hard to diagnose:

- #324: the guide recommended `openssl rand -base64 32` for
  POSTGRES_PASSWORD, but the compose interpolates the password unescaped
  into DATABASE_URL — base64's `/`, `+`, `=` break the URL. Misleadingly,
  db stays healthy (it gets the password as a plain env var) while
  api/collab/backup crash. Guide and .env.example now recommend
  `openssl rand -hex 24` for both secrets and say why; Troubleshooting
  gained the symptom line.
- #325: SETUP_ADMIN_PASSWORD's minimum (10 chars,
  packages/shared/src/auth.ts) was undocumented, and a violation crashed
  the boot with a raw ZodError naming schema fields and i18n keys.
  Failing the boot stays — deliberately, no half-seeded instance — but
  preseedFromEnv now translates validation errors into operator terms
  ("Pre-seeding failed: SETUP_ADMIN_PASSWORD must be at least 10
  characters. Fix .env and recreate the api container."). Documented in
  the guide's first-run section, .env.example, and Troubleshooting; new
  test pins the message and that nothing is half-seeded afterwards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aviRTgWCcAHUh1SBoxf6P
2026-08-04 12:44:16 +02:00

422 lines
15 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');
});
});
describe('env pre-seeding with invalid values (issue #325)', () => {
const dbName = `dorfteich_preseed_bad_${suffix}`;
let app: INestApplication;
const badEnv = {
SETUP_ADMIN_USERNAME: `preseed-bad-${suffix}`,
SETUP_ADMIN_EMAIL: `preseed-bad-${suffix}@example.org`,
SETUP_ADMIN_PASSWORD: 'short',
} 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-bad-')),
'secrets.env',
);
Object.assign(process.env, badEnv);
app = await createTestApp();
}, 60_000);
afterAll(async () => {
for (const key of Object.keys(badEnv)) delete process.env[key];
await app.close();
await dropDatabase(dbName);
});
it('fails the boot naming the SETUP_* variable, not a raw ZodError', async () => {
await expect(app.get(SetupService).preseedFromEnv()).rejects.toThrow(
/SETUP_ADMIN_PASSWORD must be at least 10 characters/,
);
// Fail-fast left nothing half-seeded: the wizard is still pending.
const status = await request(app.getHttpServer()).get('/api/v1/setup').expect(200);
expect(status.body.status).toBe('required');
});
});
});
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());
}),
});
});
});
}