feat: First-Start Create Admin Modal
This commit is contained in:
@@ -2,7 +2,11 @@ import request from 'supertest';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { CookieAccessInfo } from 'cookiejar';
|
||||
|
||||
const csrfSkipPaths = new Set<string>(['/api/v1/auth/login', '/api/v1/auth/register-first-admin']);
|
||||
const csrfSkipPaths = new Set<string>([
|
||||
'/api/v1/auth/login',
|
||||
'/api/v1/auth/register-first-admin',
|
||||
'/api/v1/setup/create-admin',
|
||||
]);
|
||||
|
||||
function attachCsrfHeader(test: request.Test, method: string, path: string, agent: any): request.Test {
|
||||
if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(method.toUpperCase())) return test;
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
process.env.DATABASE_PATH = ':memory:';
|
||||
process.env.THEMES_DIR = './themes';
|
||||
process.env.FRONTEND_DIST = './frontend/dist';
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import { HttpAdapterHost } from '@nestjs/core';
|
||||
import { Repository } from 'typeorm';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { AppModule } from '../../backend/src/app.module';
|
||||
import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter';
|
||||
import { csrfClient } from './csrf-client';
|
||||
import { initDb } from './db-helper';
|
||||
import { UserEntity } from '../../backend/src/database/entities/user.entity';
|
||||
import { SetupService } from '../../backend/src/modules/setup/setup.service';
|
||||
|
||||
describe('Setup: POST /api/v1/setup/create-admin', () => {
|
||||
let app: INestApplication;
|
||||
let users: Repository<UserEntity>;
|
||||
let setupService: SetupService;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
||||
app = moduleRef.createNestApplication();
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true }));
|
||||
const httpAdapterHost = app.get(HttpAdapterHost);
|
||||
app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
|
||||
await app.init();
|
||||
await initDb(app);
|
||||
users = app.get<Repository<UserEntity>>(getRepositoryToken(UserEntity));
|
||||
setupService = app.get(SetupService);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await users.clear();
|
||||
});
|
||||
|
||||
it('creates the very first admin and exposes a session + cookie', async () => {
|
||||
const c = csrfClient(app);
|
||||
const res = await c
|
||||
.post('/api/v1/setup/create-admin')
|
||||
.send({ username: 'first.admin', password: 'Sup3rSecret!Pass', passwordConfirm: 'Sup3rSecret!Pass' })
|
||||
.expect(201);
|
||||
|
||||
expect(res.body.accessToken).toEqual(expect.any(String));
|
||||
expect(res.body.user.role).toBe('admin');
|
||||
expect(res.body.user.username).toBe('first.admin');
|
||||
|
||||
const setCookieRaw = res.headers['set-cookie'] as unknown;
|
||||
const setCookie: string[] = Array.isArray(setCookieRaw) ? setCookieRaw : setCookieRaw ? [String(setCookieRaw)] : [];
|
||||
expect(setCookie.some((h) => String(h).startsWith('rt='))).toBe(true);
|
||||
|
||||
const adminCount = await users.count({ where: { role: 'admin' } });
|
||||
expect(adminCount).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 404 once an admin already exists (route is hidden)', async () => {
|
||||
const c = csrfClient(app);
|
||||
await c
|
||||
.post('/api/v1/setup/create-admin')
|
||||
.send({ username: 'first', password: 'Sup3rSecret!Pass', passwordConfirm: 'Sup3rSecret!Pass' })
|
||||
.expect(201);
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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');
|
||||
expect(ERROR_CODES.USERNAME_TAKEN).toBe('USERNAME_TAKEN');
|
||||
});
|
||||
|
||||
it('SetupService throws not-found (route hidden) 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' });
|
||||
});
|
||||
|
||||
it('rejects weak password with WEAK_PASSWORD', async () => {
|
||||
const c = csrfClient(app);
|
||||
const res = await c
|
||||
.post('/api/v1/setup/create-admin')
|
||||
.send({ username: 'weakpw', password: 'short', passwordConfirm: 'short' })
|
||||
.expect(400);
|
||||
expect(res.body.code).toBe('WEAK_PASSWORD');
|
||||
});
|
||||
|
||||
it('rejects passwordConfirm mismatch with VALIDATION_FAILED', async () => {
|
||||
const c = csrfClient(app);
|
||||
await c
|
||||
.post('/api/v1/setup/create-admin')
|
||||
.send({ username: 'mismatch', password: 'Sup3rSecret!Pass', passwordConfirm: 'Different!Pass1' })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('rejects invalid username characters with VALIDATION_FAILED', async () => {
|
||||
const c = csrfClient(app);
|
||||
await c
|
||||
.post('/api/v1/setup/create-admin')
|
||||
.send({ username: 'bad name!', password: 'Sup3rSecret!Pass', passwordConfirm: 'Sup3rSecret!Pass' })
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
passwordMatchValidator,
|
||||
PasswordMatchGroupLike,
|
||||
} from '../../frontend/src/app/features/setup/setup-create-admin.validators';
|
||||
|
||||
function makeGroup(password: string | null | undefined, confirm: string | null | undefined): PasswordMatchGroupLike {
|
||||
return {
|
||||
get: (name) => {
|
||||
if (name === 'password') return { value: password };
|
||||
if (name === 'passwordConfirm') return { value: confirm };
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('passwordMatchValidator', () => {
|
||||
it('returns null when either field is empty (let other validators handle required)', () => {
|
||||
expect(passwordMatchValidator(makeGroup('', 'x'))).toBeNull();
|
||||
expect(passwordMatchValidator(makeGroup('x', ''))).toBeNull();
|
||||
expect(passwordMatchValidator(makeGroup(undefined, undefined))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when passwords match', () => {
|
||||
expect(passwordMatchValidator(makeGroup('Sup3r!', 'Sup3r!'))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns { passwordMismatch: true } when passwords differ', () => {
|
||||
expect(passwordMatchValidator(makeGroup('Sup3r!', 'Sup4r!'))).toEqual({
|
||||
passwordMismatch: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Username field rules (mirroring component validators)', () => {
|
||||
const PATTERN = /^[a-zA-Z0-9_.-]+$/;
|
||||
const MIN = 3;
|
||||
const MAX = 32;
|
||||
|
||||
function isValidUsername(value: string): boolean {
|
||||
return value.length >= MIN && value.length <= MAX && PATTERN.test(value);
|
||||
}
|
||||
|
||||
it('accepts a 3-32 char username with allowed characters', () => {
|
||||
expect(isValidUsername('first.admin_01')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects usernames shorter than 3', () => {
|
||||
expect(isValidUsername('a')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects usernames with disallowed characters', () => {
|
||||
expect(isValidUsername('bad name')).toBe(false);
|
||||
expect(isValidUsername('user!')).toBe(false);
|
||||
expect(isValidUsername('user@')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects usernames longer than 32', () => {
|
||||
expect(isValidUsername('a'.repeat(33))).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user