d9ad982373
The 3.97.0-beta.0 release PR (#899) failed its backend Tests job on slideshowPublic.test.js: bootCrmDb's full migration chain crossed the suite's explicit 30s beforeAll timeout argument on a slow runner. #860 raised the config default and the jest.setTimeout pins to 120s, but hook-ARGUMENT pins override the config default and were left behind — same time-bomb, different syntax. Every beforeAll that boots the migration chain and pinned 30s/60s is raised to 120000 (16 suites). Untouched on purpose: the three suites whose pinned hooks don't run migrations (webhookDelivery, imageProcessor.storage, storageBackend) and publicQuotes' 30s pin on the rate-limit lockout test — neither grows with the migration chain. No test logic changed. Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
197 lines
8.7 KiB
JavaScript
197 lines
8.7 KiB
JavaScript
'use strict';
|
|
|
|
// First-run bootstrap service. bootCrmDb() must run BEFORE requiring the service
|
|
// so setupService shares this test's db instance (see crmDb.js note).
|
|
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-characters-long!!';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const request = require('supertest');
|
|
const { bootCrmDb, buildRouteApp } = require('./helpers/crmDb');
|
|
|
|
let db;
|
|
let cleanup;
|
|
let tmpDir;
|
|
let setupService;
|
|
let getAppSetting;
|
|
let upsertAppSetting;
|
|
let app;
|
|
|
|
const VALID_PW = 'Str0ng-Passw0rd!';
|
|
|
|
// bootCrmDb MUST run before any require of db.js (directly or transitively via a
|
|
// service/util), or db.js binds to the default path instead of the temp one.
|
|
beforeAll(async () => {
|
|
({ db, cleanup, tmpDir } = await bootCrmDb());
|
|
process.env.DATA_DIR = tmpDir; // isolate the SETUP_TOKEN file to the temp dir
|
|
setupService = require('../../src/services/setupService');
|
|
({ getAppSetting, upsertAppSetting } = require('../../src/utils/appSettings'));
|
|
app = buildRouteApp('/api/setup', require('../../src/routes/setup'));
|
|
}, 120000);
|
|
|
|
afterAll(async () => {
|
|
await cleanup();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await db('admin_users').del();
|
|
await db('app_settings').where({ setting_key: 'setup_token' }).del();
|
|
});
|
|
|
|
describe('setupService (first-run bootstrap)', () => {
|
|
it('reports needsAdmin while no admin exists', async () => {
|
|
expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: true, complete: false });
|
|
});
|
|
|
|
it('generates and persists a one-time token while no admin exists', async () => {
|
|
const token = await setupService.ensureSetupToken();
|
|
expect(token).toEqual(expect.any(String));
|
|
expect(token.length).toBeGreaterThan(20);
|
|
expect(await getAppSetting('setup_token')).toBe(token);
|
|
// Idempotent — a second call returns the same token, not a fresh one.
|
|
expect(await setupService.ensureSetupToken()).toBe(token);
|
|
});
|
|
|
|
it('stores the token as valid JSON so the Postgres jsonb column accepts it', async () => {
|
|
// Regression guard for the SQLite-only miss: a bare token string is rejected
|
|
// by Postgres jsonb ("invalid input syntax for type json"). The raw column
|
|
// value must be JSON-parseable and round-trip back to the token.
|
|
const token = await setupService.ensureSetupToken();
|
|
const row = await db('app_settings').where({ setting_key: 'setup_token' }).first();
|
|
expect(() => JSON.parse(row.setting_value)).not.toThrow();
|
|
expect(JSON.parse(row.setting_value)).toBe(token);
|
|
});
|
|
|
|
it('rejects a wrong token', async () => {
|
|
await setupService.ensureSetupToken();
|
|
await expect(
|
|
setupService.createInitialAdmin({ token: 'nope', email: 'a@b.co', password: VALID_PW })
|
|
).rejects.toMatchObject({ statusCode: 400 });
|
|
expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: true, complete: false });
|
|
});
|
|
|
|
it('rejects a weak password', async () => {
|
|
const token = await setupService.ensureSetupToken();
|
|
await expect(
|
|
setupService.createInitialAdmin({ token, email: 'a@b.co', password: 'weak' })
|
|
).rejects.toMatchObject({ statusCode: 400 });
|
|
});
|
|
|
|
it('creates the first admin as super_admin, issues a token, and burns the setup token', async () => {
|
|
const token = await setupService.ensureSetupToken();
|
|
const result = await setupService.createInitialAdmin({
|
|
token, email: 'Owner@Example.com', password: VALID_PW, ip: '203.0.113.7',
|
|
});
|
|
|
|
expect(result.user.email).toBe('owner@example.com'); // normalised
|
|
expect(result.user.role.name).toBe('super_admin');
|
|
expect(result.token).toEqual(expect.any(String));
|
|
|
|
const row = await db('admin_users').first();
|
|
const role = await db('roles').where({ name: 'super_admin' }).first();
|
|
expect(row.role_id).toBe(role.id);
|
|
expect(row.password_hash).not.toBe(VALID_PW); // hashed
|
|
|
|
// One-time: token burned, status now complete.
|
|
expect(await getAppSetting('setup_token')).toBeFalsy();
|
|
expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: false, complete: true });
|
|
});
|
|
|
|
it('writes the SETUP_TOKEN file while pending and removes it once setup completes', async () => {
|
|
const tokenFile = path.join(tmpDir, 'SETUP_TOKEN');
|
|
const token = await setupService.ensureSetupToken();
|
|
expect(fs.readFileSync(tokenFile, 'utf8').trim()).toBe(token);
|
|
await setupService.createInitialAdmin({ token, email: 'owner@example.com', password: VALID_PW });
|
|
expect(fs.existsSync(tokenFile)).toBe(false); // burned in DB + file removed
|
|
});
|
|
|
|
it('refuses to create a second admin (setup already complete)', async () => {
|
|
const token = await setupService.ensureSetupToken();
|
|
await setupService.createInitialAdmin({ token, email: 'first@example.com', password: VALID_PW });
|
|
await expect(
|
|
setupService.createInitialAdmin({ token, email: 'second@example.com', password: VALID_PW })
|
|
).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: 'a@example.com', password: VALID_PW }),
|
|
setupService.createInitialAdmin({ token, email: 'b@example.com', 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: 'first@example.com', password: VALID_PW });
|
|
// Simulate a stale token left in settings, then re-run the boot hook.
|
|
await upsertAppSetting('setup_token', JSON.stringify('stale'), 'string');
|
|
expect(await setupService.ensureSetupToken()).toBeNull();
|
|
expect(await getAppSetting('setup_token')).toBeFalsy();
|
|
});
|
|
});
|
|
|
|
describe('setup routes', () => {
|
|
it('GET /api/setup/status reports needsAdmin', async () => {
|
|
const res = await request(app).get('/api/setup/status');
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ needsAdmin: true, complete: false });
|
|
});
|
|
|
|
it('POST /api/setup/verify-token accepts the right token without burning it (200)', async () => {
|
|
const token = await setupService.ensureSetupToken();
|
|
const res = await request(app).post('/api/setup/verify-token').send({ token });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ valid: true });
|
|
// Token is NOT consumed — it still works for the actual create.
|
|
expect(await getAppSetting('setup_token')).toBe(token);
|
|
});
|
|
|
|
it('POST /api/setup/verify-token rejects a wrong token (400, field token)', async () => {
|
|
await setupService.ensureSetupToken();
|
|
const res = await request(app).post('/api/setup/verify-token').send({ token: 'nope' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.field).toBe('token');
|
|
});
|
|
|
|
it('POST /api/setup/verify-token is closed once an admin exists (409)', async () => {
|
|
const token = await setupService.ensureSetupToken();
|
|
await setupService.createInitialAdmin({ token, email: 'first@example.com', password: VALID_PW });
|
|
const res = await request(app).post('/api/setup/verify-token').send({ token });
|
|
expect(res.status).toBe(409);
|
|
});
|
|
|
|
it('POST /api/setup/admin rejects a wrong token (400)', async () => {
|
|
await setupService.ensureSetupToken();
|
|
const res = await request(app)
|
|
.post('/api/setup/admin')
|
|
.send({ token: 'nope', email: 'a@b.co', password: VALID_PW });
|
|
expect(res.status).toBe(400);
|
|
expect(await setupService.getSetupStatus()).toMatchObject({ needsAdmin: true });
|
|
});
|
|
|
|
it('POST /api/setup/admin creates the first admin + sets the auth cookie (201)', async () => {
|
|
const token = await setupService.ensureSetupToken();
|
|
const res = await request(app)
|
|
.post('/api/setup/admin')
|
|
.send({ token, email: 'owner@example.com', password: VALID_PW });
|
|
expect(res.status).toBe(201);
|
|
expect(res.body.user.role.name).toBe('super_admin');
|
|
expect((res.headers['set-cookie'] || []).join(';')).toMatch(/admin_token/);
|
|
expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: false, complete: true });
|
|
});
|
|
|
|
it('POST /api/setup/admin is closed once an admin exists (409)', async () => {
|
|
const token = await setupService.ensureSetupToken();
|
|
await setupService.createInitialAdmin({ token, email: 'first@example.com', password: VALID_PW });
|
|
const res = await request(app)
|
|
.post('/api/setup/admin')
|
|
.send({ token, email: 'second@example.com', password: VALID_PW });
|
|
expect(res.status).toBe(409);
|
|
});
|
|
});
|