feat: First-Start Create Admin Modal 1.05
This commit is contained in:
@@ -12,11 +12,14 @@ import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-e
|
||||
import { csrfClient } from './csrf-client';
|
||||
import { initDb } from './db-helper';
|
||||
import { UserEntity } from '../../backend/src/database/entities/user.entity';
|
||||
import { RefreshTokenEntity } from '../../backend/src/database/entities/refresh-token.entity';
|
||||
import { SetupService } from '../../backend/src/modules/setup/setup.service';
|
||||
import { ERROR_CODES } from '../../backend/src/common/errors/error-codes';
|
||||
|
||||
describe('Setup: POST /api/v1/setup/create-admin', () => {
|
||||
let app: INestApplication;
|
||||
let users: Repository<UserEntity>;
|
||||
let refreshTokens: Repository<RefreshTokenEntity>;
|
||||
let setupService: SetupService;
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -28,6 +31,7 @@ describe('Setup: POST /api/v1/setup/create-admin', () => {
|
||||
await app.init();
|
||||
await initDb(app);
|
||||
users = app.get<Repository<UserEntity>>(getRepositoryToken(UserEntity));
|
||||
refreshTokens = app.get<Repository<RefreshTokenEntity>>(getRepositoryToken(RefreshTokenEntity));
|
||||
setupService = app.get(SetupService);
|
||||
});
|
||||
|
||||
@@ -36,6 +40,7 @@ describe('Setup: POST /api/v1/setup/create-admin', () => {
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await refreshTokens.clear();
|
||||
await users.clear();
|
||||
});
|
||||
|
||||
@@ -58,7 +63,7 @@ describe('Setup: POST /api/v1/setup/create-admin', () => {
|
||||
expect(adminCount).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 404 once an admin already exists (route is hidden)', async () => {
|
||||
it('returns 409 SYSTEM_INITIALIZED once an admin already exists', async () => {
|
||||
const c = csrfClient(app);
|
||||
await c
|
||||
.post('/api/v1/setup/create-admin')
|
||||
@@ -68,24 +73,24 @@ describe('Setup: POST /api/v1/setup/create-admin', () => {
|
||||
const res = await c
|
||||
.post('/api/v1/setup/create-admin')
|
||||
.send({ username: 'second', password: 'Sup3rSecret!Pass', passwordConfirm: 'Sup3rSecret!Pass' });
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toBeDefined();
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.code).toBe(ERROR_CODES.SYSTEM_INITIALIZED);
|
||||
});
|
||||
|
||||
it('SetupService would map a username collision to USERNAME_TAKEN (reached only during a race before init)', () => {
|
||||
// The current ordering (route hidden after init) means USERNAME_TAKEN is
|
||||
// reachable only when two requests enter the transaction concurrently
|
||||
// before any admin exists. Verify the error code is present in the
|
||||
// error-codes enum so the frontend can switch on it.
|
||||
const { ERROR_CODES } = require('../../backend/src/common/errors/error-codes');
|
||||
// With the in-process bootstrap serialization in place, USERNAME_TAKEN
|
||||
// remains a defensive code: it is reachable only if two concurrent
|
||||
// requests with the same username somehow both slip past the lock.
|
||||
// Verify the code is present in the error-codes enum so the frontend
|
||||
// can switch on it.
|
||||
expect(ERROR_CODES.USERNAME_TAKEN).toBe('USERNAME_TAKEN');
|
||||
});
|
||||
|
||||
it('SetupService throws not-found (route hidden) after init', async () => {
|
||||
it('SetupService throws SYSTEM_INITIALIZED (409) after init', async () => {
|
||||
await setupService.createAdmin('init', 'Sup3rSecret!Pass', 'Sup3rSecret!Pass', '127.0.0.1');
|
||||
await expect(
|
||||
setupService.createAdmin('newone', 'Sup3rSecret!Pass', 'Sup3rSecret!Pass', '127.0.0.1'),
|
||||
).rejects.toMatchObject({ code: 'NOT_FOUND' });
|
||||
).rejects.toMatchObject({ code: ERROR_CODES.SYSTEM_INITIALIZED });
|
||||
});
|
||||
|
||||
it('rejects weak password with WEAK_PASSWORD', async () => {
|
||||
@@ -112,4 +117,34 @@ describe('Setup: POST /api/v1/setup/create-admin', () => {
|
||||
.send({ username: 'bad name!', password: 'Sup3rSecret!Pass', passwordConfirm: 'Sup3rSecret!Pass' })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('only one of two concurrent first-admin requests succeeds; the other receives a controlled conflict', async () => {
|
||||
// Two independent Supertest agents simulate two separate browser
|
||||
// connections that arrive within the same event-loop tick and share no
|
||||
// cookies, mirroring the production race reproduction.
|
||||
const a = csrfClient(app);
|
||||
const b = csrfClient(app);
|
||||
|
||||
const [resA, resB] = await Promise.all([
|
||||
a
|
||||
.post('/api/v1/setup/create-admin')
|
||||
.send({ username: 'adminAlpha', password: 'Sup3rSecret!Pass', passwordConfirm: 'Sup3rSecret!Pass' }),
|
||||
b
|
||||
.post('/api/v1/setup/create-admin')
|
||||
.send({ username: 'adminBeta', password: 'Sup3rSecret!Pass', passwordConfirm: 'Sup3rSecret!Pass' }),
|
||||
]);
|
||||
|
||||
const statuses = [resA.status, resB.status].sort();
|
||||
expect(statuses).toEqual([201, 409]);
|
||||
|
||||
const loser = resA.status === 409 ? resA : resB;
|
||||
expect(loser.body.code).toBe(ERROR_CODES.SYSTEM_INITIALIZED);
|
||||
|
||||
const admins = await users.find({ where: { role: 'admin' } });
|
||||
expect(admins).toHaveLength(1);
|
||||
expect(['adminAlpha', 'adminBeta']).toContain(admins[0].username);
|
||||
|
||||
const sessions = await refreshTokens.count();
|
||||
expect(sessions).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user