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
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
422 lines
15 KiB
TypeScript
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());
|
|
}),
|
|
});
|
|
});
|
|
});
|
|
}
|