fix(setup): address PR #714 review — password UX, script token, race, nits
Blockers: - SetupPage now mirrors the server password rule (>=8 with upper/lower/digit) so a green client isn't bounced by the server; server errors carry a `field` (routes/setup.js) that the client maps to a translated key instead of rendering raw English. New i18n: setup.invalidToken, setup.passwordRequirements. - picpeak-setup.sh: the ADMIN_CREDENTIALS.txt block no longer dead-ends on the wizard path — when no legacy admin was seeded it prints the one-time setup token (from data/SETUP_TOKEN / docker compose logs) and points at /setup. Concern: - createInitialAdmin creates the admin + burns the token in ONE transaction, atomically claiming the token (null-if-present, expect 1 row) so a double-submit can't create two super_admins. Cross-DB (whereNotNull, trx-only writes). Added a concurrency test. Nits: - SetupPage redirects to /login when /setup/status errors (no form flash on a configured instance). - Dropped the unused DATABASE_URL from docker-compose.yml. - Documented why secrets are chmod 644 (three different reader users).
This commit is contained in:
@@ -113,6 +113,18 @@ describe('setupService (first-run bootstrap)', () => {
|
||||
).rejects.toMatchObject({ statusCode: 409 });
|
||||
});
|
||||
|
||||
it('serialises a double-submit — two concurrent valid-token calls create only one admin', async () => {
|
||||
const token = await setupService.ensureSetupToken();
|
||||
const results = await Promise.allSettled([
|
||||
setupService.createInitialAdmin({ token, email: '[email protected]', password: VALID_PW }),
|
||||
setupService.createInitialAdmin({ token, email: '[email protected]', password: VALID_PW }),
|
||||
]);
|
||||
const fulfilled = results.filter((r) => r.status === 'fulfilled');
|
||||
expect(fulfilled).toHaveLength(1); // the atomic token claim lets exactly one win
|
||||
const count = await db('admin_users').count({ c: '*' }).first();
|
||||
expect(Number(count.c)).toBe(1);
|
||||
});
|
||||
|
||||
it('ensureSetupToken clears any stale token once an admin exists', async () => {
|
||||
const token = await setupService.ensureSetupToken();
|
||||
await setupService.createInitialAdmin({ token, email: '[email protected]', password: VALID_PW });
|
||||
|
||||
@@ -45,7 +45,9 @@ router.post('/admin', [
|
||||
res.status(201).json({ user: result.user });
|
||||
} catch (err) {
|
||||
if (err.statusCode) {
|
||||
return res.status(err.statusCode).json({ error: err.message });
|
||||
// `field` (token/email/password) lets the client show a translated
|
||||
// message instead of rendering the raw English error verbatim.
|
||||
return res.status(err.statusCode).json({ error: err.message, field: err.details || undefined });
|
||||
}
|
||||
logger.error('[setup] createInitialAdmin failed', { error: err.message });
|
||||
return res.status(500).json({ error: 'Setup failed' });
|
||||
|
||||
@@ -104,22 +104,39 @@ async function createInitialAdmin({ token, email, password, ip }) {
|
||||
if (!role) {
|
||||
throw new ConflictError('super_admin role missing — database not initialised');
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, getBcryptRounds());
|
||||
const inserted = await db('admin_users').insert({
|
||||
username: cleanEmail,
|
||||
email: cleanEmail,
|
||||
password_hash: passwordHash,
|
||||
role_id: role.id,
|
||||
is_active: formatBoolean(true),
|
||||
must_change_password: formatBoolean(false),
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
}).returning('id');
|
||||
const id = inserted[0]?.id || inserted[0];
|
||||
|
||||
// Burn the one-time token (DB + file) — the endpoint is now permanently closed.
|
||||
await clearSetupToken();
|
||||
// Create the admin and burn the token ATOMICALLY. The claim (null the token
|
||||
// row expecting exactly one match) serialises concurrent valid-token submits,
|
||||
// so a double-submit can't create two super_admins. All writes use `trx`
|
||||
// (never the global db) to avoid the SQLite in-transaction deadlock.
|
||||
const id = await db.transaction(async (trx) => {
|
||||
const claimed = await trx('app_settings')
|
||||
.where({ setting_key: SETUP_TOKEN_KEY })
|
||||
.whereNotNull('setting_value')
|
||||
.update({ setting_value: null, updated_at: new Date() });
|
||||
if (claimed !== 1) {
|
||||
throw new ConflictError('Setup already completed — an admin account exists');
|
||||
}
|
||||
const cnt = await trx('admin_users').count({ c: '*' }).first();
|
||||
if (Number(cnt?.c || 0) !== 0) {
|
||||
throw new ConflictError('Setup already completed — an admin account exists');
|
||||
}
|
||||
const inserted = await trx('admin_users').insert({
|
||||
username: cleanEmail,
|
||||
email: cleanEmail,
|
||||
password_hash: passwordHash,
|
||||
role_id: role.id,
|
||||
is_active: formatBoolean(true),
|
||||
must_change_password: formatBoolean(false),
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
}).returning('id');
|
||||
return inserted[0]?.id || inserted[0];
|
||||
});
|
||||
|
||||
// DB token cleared inside the tx; remove the on-disk file too (best-effort).
|
||||
try { fs.unlinkSync(setupTokenFilePath()); } catch (_) { /* best-effort */ }
|
||||
logger.info(`[setup] Initial super_admin created (id=${id}, email=${cleanEmail})`);
|
||||
|
||||
const authToken = jwt.sign(
|
||||
|
||||
Reference in New Issue
Block a user