187 lines
7.6 KiB
TypeScript
187 lines
7.6 KiB
TypeScript
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 { DataSource, 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 { 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 () => {
|
|
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));
|
|
refreshTokens = app.get<Repository<RefreshTokenEntity>>(getRepositoryToken(RefreshTokenEntity));
|
|
setupService = app.get(SetupService);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app.close();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
// The deployment-wide last-admin triggers reject the deletion of
|
|
// the last admin row, which is exactly what `clear()` would attempt
|
|
// when a prior test left a sole admin in the table. Disable the
|
|
// triggers for the duration of the cleanup, then re-enable them.
|
|
const ds = app.get(DataSource);
|
|
await ds.query(`DROP TRIGGER IF EXISTS "trg_user_last_admin_update"`);
|
|
await ds.query(`DROP TRIGGER IF EXISTS "trg_user_last_admin_delete"`);
|
|
try {
|
|
await refreshTokens.clear();
|
|
await users.clear();
|
|
} finally {
|
|
await ds.query(`
|
|
CREATE TRIGGER IF NOT EXISTS "trg_user_last_admin_update"
|
|
BEFORE UPDATE OF "role" ON "user"
|
|
FOR EACH ROW
|
|
WHEN OLD."role" = 'admin' AND NEW."role" <> 'admin'
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM "user" AS "other"
|
|
WHERE "other"."role" = 'admin' AND "other"."id" <> OLD."id"
|
|
)
|
|
BEGIN
|
|
SELECT RAISE(ABORT, 'LAST_ADMIN');
|
|
END;
|
|
`);
|
|
await ds.query(`
|
|
CREATE TRIGGER IF NOT EXISTS "trg_user_last_admin_delete"
|
|
BEFORE DELETE ON "user"
|
|
FOR EACH ROW
|
|
WHEN OLD."role" = 'admin'
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM "user" AS "other"
|
|
WHERE "other"."role" = 'admin' AND "other"."id" <> OLD."id"
|
|
)
|
|
BEGIN
|
|
SELECT RAISE(ABORT, 'LAST_ADMIN');
|
|
END;
|
|
`);
|
|
}
|
|
});
|
|
|
|
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 409 SYSTEM_INITIALIZED once an admin already exists', 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(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)', () => {
|
|
// 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 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: ERROR_CODES.SYSTEM_INITIALIZED });
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|