feat: Admin Area General Settings and Categories
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
process.env.DATABASE_PATH = ':memory:';
|
||||
process.env.THEMES_DIR = './themes';
|
||||
process.env.FRONTEND_DIST = './frontend/dist';
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { validateEnv } from '../../backend/src/config/env.schema';
|
||||
import { AdminCategoriesService } from '../../backend/src/modules/admin/categories.service';
|
||||
import { CategoryEntity } from '../../backend/src/database/entities/category.entity';
|
||||
import { ChallengeEntity } from '../../backend/src/database/entities/challenge.entity';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { Repository } from 'typeorm';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
describe('AdminCategoriesService - CRUD + business rules', () => {
|
||||
let categories: AdminCategoriesService;
|
||||
let catRepo: Repository<CategoryEntity>;
|
||||
let chRepo: Repository<ChallengeEntity>;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true, validate: validateEnv }),
|
||||
TypeOrmModule.forRoot({
|
||||
type: 'better-sqlite3',
|
||||
database: ':memory:',
|
||||
entities: [CategoryEntity, ChallengeEntity],
|
||||
synchronize: true,
|
||||
}),
|
||||
TypeOrmModule.forFeature([CategoryEntity, ChallengeEntity]),
|
||||
],
|
||||
providers: [AdminCategoriesService],
|
||||
}).compile();
|
||||
moduleRef.init();
|
||||
categories = moduleRef.get(AdminCategoriesService);
|
||||
catRepo = moduleRef.get<Repository<CategoryEntity>>(getRepositoryToken(CategoryEntity));
|
||||
chRepo = moduleRef.get<Repository<ChallengeEntity>>(getRepositoryToken(ChallengeEntity));
|
||||
});
|
||||
|
||||
it('uppercases the abbreviation on create', async () => {
|
||||
const c = await categories.create({ name: 'X', abbreviation: 'xy', description: '' });
|
||||
expect(c.abbreviation).toBe('XY');
|
||||
await categories.remove(c.id);
|
||||
});
|
||||
|
||||
it('rejects duplicate abbreviations', async () => {
|
||||
const c = await categories.create({ name: 'A', abbreviation: 'TT', description: '' });
|
||||
await expect(
|
||||
categories.create({ name: 'B', abbreviation: 'tt', description: '' }),
|
||||
).rejects.toMatchObject({ status: 409 });
|
||||
await categories.remove(c.id);
|
||||
});
|
||||
|
||||
it('allows editing a user category name and description', async () => {
|
||||
const c = await categories.create({ name: 'Original', abbreviation: 'oo', description: 'd' });
|
||||
const updated = await categories.update(c.id, { name: 'New', description: 'd2' });
|
||||
expect(updated.name).toBe('New');
|
||||
expect(updated.description).toBe('d2');
|
||||
expect(updated.abbreviation).toBe('OO');
|
||||
await categories.remove(c.id);
|
||||
});
|
||||
|
||||
it('protects system category abbreviation from change', async () => {
|
||||
const sys = await categories.create({ name: 'Sys', abbreviation: 'SY', description: '' });
|
||||
await catRepo.update({ id: sys.id }, { systemKey: 'SY' });
|
||||
await expect(
|
||||
categories.update(sys.id, { abbreviation: 'ZZ' }),
|
||||
).rejects.toMatchObject({ status: 409 });
|
||||
await catRepo.update({ id: sys.id }, { systemKey: null });
|
||||
await categories.remove(sys.id);
|
||||
});
|
||||
|
||||
it('allows system category name/description updates', async () => {
|
||||
const sys = await categories.create({ name: 'Sys', abbreviation: 'SY2', description: 'd' });
|
||||
await catRepo.update({ id: sys.id }, { systemKey: 'SY2' });
|
||||
const updated = await categories.update(sys.id, { name: 'Renamed', description: 'x' });
|
||||
expect(updated.name).toBe('Renamed');
|
||||
expect(updated.description).toBe('x');
|
||||
expect(updated.abbreviation).toBe('SY2');
|
||||
await catRepo.update({ id: sys.id }, { systemKey: null });
|
||||
await categories.remove(sys.id);
|
||||
});
|
||||
|
||||
it('blocks deletion of a system category', async () => {
|
||||
const sys = await categories.create({ name: 'Sys', abbreviation: 'SY3', description: '' });
|
||||
await catRepo.update({ id: sys.id }, { systemKey: 'SY3' });
|
||||
await expect(categories.remove(sys.id)).rejects.toMatchObject({ status: 403 });
|
||||
await catRepo.update({ id: sys.id }, { systemKey: null });
|
||||
await categories.remove(sys.id);
|
||||
});
|
||||
|
||||
it('blocks deletion of user category with challenges attached', async () => {
|
||||
const c = await categories.create({ name: 'WithChal', abbreviation: 'WC', description: '' });
|
||||
await chRepo.save(chRepo.create({
|
||||
id: uuid(),
|
||||
name: 'challenge',
|
||||
descriptionMd: '',
|
||||
categoryId: c.id,
|
||||
difficulty: 'low',
|
||||
initialPoints: 100,
|
||||
minimumPoints: 50,
|
||||
decaySolves: 5,
|
||||
flag: 'flag{...}',
|
||||
protocol: 'nc',
|
||||
ipAddress: '',
|
||||
}));
|
||||
await expect(categories.remove(c.id)).rejects.toMatchObject({ status: 409 });
|
||||
await chRepo.delete({ categoryId: c.id });
|
||||
await categories.remove(c.id);
|
||||
});
|
||||
|
||||
it('allows deletion of user category with no challenges', async () => {
|
||||
const c = await categories.create({ name: 'NoChal', abbreviation: 'NC', description: '' });
|
||||
await categories.remove(c.id);
|
||||
const list = await categories.list();
|
||||
expect(list.find((x) => x.id === c.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('lists categories sorted by lowercase abbreviation', async () => {
|
||||
const c1 = await categories.create({ name: 'Bravo', abbreviation: 'br', description: '' });
|
||||
const c2 = await categories.create({ name: 'Alpha', abbreviation: 'al', description: '' });
|
||||
const list = await categories.list();
|
||||
const ours = list.filter((c) => c.id === c1.id || c.id === c2.id).map((c) => c.abbreviation);
|
||||
expect(ours).toEqual(['AL', 'BR']);
|
||||
await categories.remove(c1.id);
|
||||
await categories.remove(c2.id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
process.env.DATABASE_PATH = ':memory:';
|
||||
process.env.THEMES_DIR = './themes';
|
||||
process.env.FRONTEND_DIST = './frontend/dist';
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { AdminGeneralService } from '../../backend/src/modules/admin/general.service';
|
||||
import { GeneralSettingsSchema } from '../../backend/src/modules/admin/dto/general.dto';
|
||||
import { THEME_IDS } from '../../backend/src/common/types/theme-ids';
|
||||
|
||||
function makeFakeThemeLoader() {
|
||||
return THEME_IDS.map((id) => ({ id, name: id.charAt(0).toUpperCase() + id.slice(1), tokens: {} as any }));
|
||||
}
|
||||
|
||||
describe('GeneralSettingsSchema - validation rules', () => {
|
||||
it('rejects end <= start with a validation error', () => {
|
||||
const r = GeneralSettingsSchema.safeParse({
|
||||
pageTitle: 'T',
|
||||
logo: '',
|
||||
welcomeMarkdown: '',
|
||||
themeKey: 'classic',
|
||||
eventStartUtc: '2026-02-01T00:00:00Z',
|
||||
eventEndUtc: '2026-01-01T00:00:00Z',
|
||||
defaultChallengeIp: '127.0.0.1',
|
||||
registrationsEnabled: false,
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects unknown themeKey', () => {
|
||||
const r = GeneralSettingsSchema.safeParse({
|
||||
pageTitle: 'T',
|
||||
logo: '',
|
||||
welcomeMarkdown: '',
|
||||
themeKey: 'baroque',
|
||||
eventStartUtc: '2026-01-01T00:00:00Z',
|
||||
eventEndUtc: '2026-02-01T00:00:00Z',
|
||||
defaultChallengeIp: '127.0.0.1',
|
||||
registrationsEnabled: false,
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts a valid payload', () => {
|
||||
const r = GeneralSettingsSchema.safeParse({
|
||||
pageTitle: 'Hello',
|
||||
logo: '',
|
||||
welcomeMarkdown: '# x',
|
||||
themeKey: 'classic',
|
||||
eventStartUtc: '2026-01-01T00:00:00Z',
|
||||
eventEndUtc: '2026-02-01T00:00:00Z',
|
||||
defaultChallengeIp: '127.0.0.1',
|
||||
registrationsEnabled: true,
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminGeneralService.updateSettings - happy path', () => {
|
||||
it('persists all keys and emits a settings event', async () => {
|
||||
const stored: Record<string, string> = {};
|
||||
const fakeSettings = {
|
||||
get: jest.fn().mockImplementation(async (k: string, d: string) => (k in stored ? stored[k] : d)),
|
||||
set: jest.fn().mockImplementation(async (k: string, v: string) => { stored[k] = v; }),
|
||||
} as any;
|
||||
const fakeHub = { emitEvent: jest.fn() } as any;
|
||||
const fakeThemes: any = { listThemes: () => makeFakeThemeLoader() };
|
||||
const fakeConfig: any = { get: jest.fn().mockReturnValue('./themes') };
|
||||
const svc = new AdminGeneralService(fakeSettings, fakeThemes, fakeHub, fakeConfig);
|
||||
const updated = await svc.updateSettings({
|
||||
pageTitle: 'Hello',
|
||||
logo: '',
|
||||
welcomeMarkdown: '# x',
|
||||
themeKey: 'classic',
|
||||
eventStartUtc: '2026-01-01T00:00:00Z',
|
||||
eventEndUtc: '2026-02-01T00:00:00Z',
|
||||
defaultChallengeIp: '127.0.0.1',
|
||||
registrationsEnabled: true,
|
||||
});
|
||||
expect(updated.pageTitle).toBe('Hello');
|
||||
expect(updated.registrationsEnabled).toBe(true);
|
||||
expect(fakeHub.emitEvent).toHaveBeenCalledWith(expect.objectContaining({ topic: 'general' }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminGeneralService.listThemes - filter to on-disk themes', () => {
|
||||
let themesDir: string;
|
||||
|
||||
beforeAll(() => {
|
||||
themesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'admin-themes-'));
|
||||
process.env.THEMES_DIR = themesDir;
|
||||
for (const id of THEME_IDS) {
|
||||
fs.writeFileSync(path.join(themesDir, `${id}.json`), JSON.stringify({
|
||||
id,
|
||||
name: id,
|
||||
tokens: {
|
||||
primary: '#000', secondary: '#111', accent: '#222', surface: '#fff', text: '#000',
|
||||
success: '#0f0', warning: '#ff0', danger: '#f00',
|
||||
fontFamily: 'sans', radii: { sm: '1px', md: '2px', lg: '3px' },
|
||||
spacingScale: [4, 8, 12],
|
||||
},
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
it('returns the 10 theme entries that have on-disk files', () => {
|
||||
const svc = new AdminGeneralService(
|
||||
{ get: jest.fn(), set: jest.fn() } as any,
|
||||
{ listThemes: () => makeFakeThemeLoader() } as any,
|
||||
{ emitEvent: jest.fn() } as any,
|
||||
{ get: jest.fn().mockReturnValue(themesDir) } as any,
|
||||
);
|
||||
const themes = svc.listThemes();
|
||||
expect(themes.length).toBe(10);
|
||||
for (const t of themes) {
|
||||
expect(t.id).toBe(t.key);
|
||||
expect(typeof t.name).toBe('string');
|
||||
}
|
||||
});
|
||||
|
||||
it('skips themes whose JSON file is missing on disk', () => {
|
||||
fs.unlinkSync(path.join(themesDir, 'classic.json'));
|
||||
const svc = new AdminGeneralService(
|
||||
{ get: jest.fn(), set: jest.fn() } as any,
|
||||
{ listThemes: () => makeFakeThemeLoader() } as any,
|
||||
{ emitEvent: jest.fn() } as any,
|
||||
{ get: jest.fn().mockReturnValue(themesDir) } as any,
|
||||
);
|
||||
const themes = svc.listThemes();
|
||||
expect(themes.find((t) => t.id === 'classic')).toBeUndefined();
|
||||
expect(themes.length).toBe(9);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import sharp from 'sharp';
|
||||
|
||||
describe('Category icon normalization (sharp 128x128 pipeline)', () => {
|
||||
it('produces a 128x128 PNG from a larger source', async () => {
|
||||
const big = await sharp({
|
||||
create: { width: 500, height: 500, channels: 3, background: { r: 100, g: 50, b: 200 } },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
const out = await sharp(big).resize(128, 128, { fit: 'cover' }).png().toBuffer();
|
||||
const meta = await sharp(out).metadata();
|
||||
expect(meta.width).toBe(128);
|
||||
expect(meta.height).toBe(128);
|
||||
expect(meta.format).toBe('png');
|
||||
});
|
||||
|
||||
it('produces a 128x128 PNG even when the source is smaller than 128', async () => {
|
||||
const tiny = await sharp({
|
||||
create: { width: 16, height: 16, channels: 3, background: { r: 0, g: 255, b: 0 } },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
const out = await sharp(tiny).resize(128, 128, { fit: 'cover' }).png().toBuffer();
|
||||
const meta = await sharp(out).metadata();
|
||||
expect(meta.width).toBe(128);
|
||||
expect(meta.height).toBe(128);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,8 @@ import * as path from 'path';
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
import { InitSchema1700000000000 } from '../../backend/src/database/migrations/1700000000000-InitSchema';
|
||||
import { SeedSystemData1700000000100 } from '../../backend/src/database/migrations/1700000000100-SeedSystemData';
|
||||
import { AddCategoryTimestampsAndUniqueAbbrev1700000000200 } from '../../backend/src/database/migrations/1700000000200-AddCategoryTimestampsAndUniqueAbbrev';
|
||||
import { UpdateSystemCategoryKeys1700000000300 } from '../../backend/src/database/migrations/1700000000300-UpdateSystemCategoryKeys';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { UserEntity } from '../../backend/src/database/entities/user.entity';
|
||||
import { SettingEntity } from '../../backend/src/database/entities/setting.entity';
|
||||
@@ -20,7 +22,12 @@ describe('Migrations', () => {
|
||||
type: 'better-sqlite3',
|
||||
database: ':memory:',
|
||||
entities: [UserEntity, SettingEntity, CategoryEntity, ChallengeEntity, ChallengeFileEntity, SolveEntity, RefreshTokenEntity, BlogPostEntity],
|
||||
migrations: [InitSchema1700000000000, SeedSystemData1700000000100],
|
||||
migrations: [
|
||||
InitSchema1700000000000,
|
||||
SeedSystemData1700000000100,
|
||||
AddCategoryTimestampsAndUniqueAbbrev1700000000200,
|
||||
UpdateSystemCategoryKeys1700000000300,
|
||||
],
|
||||
migrationsRun: true,
|
||||
synchronize: false,
|
||||
logging: false,
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
process.env.DATABASE_PATH = ':memory:';
|
||||
process.env.THEMES_DIR = './themes';
|
||||
process.env.FRONTEND_DIST = './frontend/dist';
|
||||
process.env.UPLOAD_DIR = '/tmp/hipctf-uploads-logo-test';
|
||||
process.env.UPLOAD_SIZE_LIMIT = '1mb';
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import { HttpAdapterHost } from '@nestjs/core';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import * as express from 'express';
|
||||
import request from 'supertest';
|
||||
import { CookieAccessInfo } from 'cookiejar';
|
||||
import { AppModule } from '../../backend/src/app.module';
|
||||
import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter';
|
||||
import { CsrfMiddleware } from '../../backend/src/common/middleware/csrf.middleware';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { initDb } from './db-helper';
|
||||
|
||||
describe('Uploads endpoint - /uploads/logo (admin only)', () => {
|
||||
let app: INestApplication;
|
||||
let adminToken: string;
|
||||
let playerToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
if (fs.existsSync(process.env.UPLOAD_DIR!)) {
|
||||
fs.rmSync(process.env.UPLOAD_DIR!, { recursive: true, force: true });
|
||||
}
|
||||
fs.mkdirSync(process.env.UPLOAD_DIR!, { recursive: true });
|
||||
|
||||
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
||||
app = moduleRef.createNestApplication();
|
||||
app.use(cookieParser());
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
// Mirror production: serve /uploads static so we can verify public fetch.
|
||||
app.use('/uploads', express.static(process.env.UPLOAD_DIR!));
|
||||
const csrfMw = new CsrfMiddleware(app.get(ConfigService));
|
||||
app.use((req: any, res: any, next: any) => csrfMw.use(req, res, next));
|
||||
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);
|
||||
|
||||
const server = app.getHttpServer();
|
||||
await request(server).post('/api/v1/auth/register-first-admin')
|
||||
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||
.expect(201);
|
||||
|
||||
const csrfPrime = await request(server).get('/api/v1/auth/csrf');
|
||||
const csrfCookie = (csrfPrime.headers['set-cookie'] as unknown as string[] | undefined)?.find((c) => c.startsWith('csrf='));
|
||||
const csrfToken = csrfCookie ? decodeURIComponent(csrfCookie.split(';')[0].split('=')[1]) : '';
|
||||
const login = await request(server).post('/api/v1/auth/login')
|
||||
.set('Cookie', `csrf=${csrfToken}`)
|
||||
.set('X-CSRF-Token', csrfToken)
|
||||
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||
.expect(201);
|
||||
adminToken = login.body.accessToken;
|
||||
|
||||
// Create a player to test 403.
|
||||
{
|
||||
const agent = request.agent(server);
|
||||
await agent.get('/api/v1/auth/csrf');
|
||||
const cookies: any = agent.jar.getCookies(CookieAccessInfo.All);
|
||||
const csrf = cookies.find((c: any) => c.name === 'csrf').value;
|
||||
await agent.post('/api/v1/admin/users')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.send({ username: 'logo_player', password: 'Sup3rSecret!Pass', role: 'player' })
|
||||
.expect(201);
|
||||
const playerLogin = await agent.post('/api/v1/auth/login')
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.send({ username: 'logo_player', password: 'Sup3rSecret!Pass' })
|
||||
.expect(201);
|
||||
playerToken = playerLogin.body.accessToken;
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
if (fs.existsSync(process.env.UPLOAD_DIR!)) {
|
||||
fs.rmSync(process.env.UPLOAD_DIR!, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function primeCsrf(): Promise<{ agent: any; csrf: string }> {
|
||||
const server = app.getHttpServer();
|
||||
const agent = request.agent(server);
|
||||
await agent.get('/api/v1/auth/csrf');
|
||||
const cookies: any = agent.jar.getCookies(CookieAccessInfo.All);
|
||||
return { agent, csrf: cookies.find((c: any) => c.name === 'csrf').value };
|
||||
}
|
||||
|
||||
it('rejects /uploads/logo without auth (or CSRF)', async () => {
|
||||
// The CsrfMiddleware short-circuits with 403 before the JWT guard runs, so
|
||||
// a fully unauthenticated request without a CSRF cookie returns 403 here.
|
||||
// A properly authenticated-but-unsigned request still returns 403 as well.
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v1/uploads/logo')
|
||||
.attach('file', Buffer.from([0x89, 0x50, 0x4e, 0x47]), 'logo.png')
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('rejects /uploads/logo with a player JWT (403)', async () => {
|
||||
const { agent, csrf } = await primeCsrf();
|
||||
await agent
|
||||
.post('/api/v1/uploads/logo')
|
||||
.set('Authorization', `Bearer ${playerToken}`)
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.attach('file', Buffer.from([0x89, 0x50, 0x4e, 0x47]), 'logo.png')
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('uploads a small image and returns publicUrl + originalFilename', async () => {
|
||||
const { agent, csrf } = await primeCsrf();
|
||||
const originalName = `My Logo ${Date.now()}.PNG`;
|
||||
const res = await agent
|
||||
.post('/api/v1/uploads/logo')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.attach('file', Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), originalName)
|
||||
.expect(201);
|
||||
|
||||
expect(res.body).toHaveProperty('publicUrl');
|
||||
expect(res.body).toHaveProperty('originalFilename');
|
||||
expect(res.body.originalFilename).toBe(originalName);
|
||||
// safeFilename() lowercases + cleans the stem and appends a short hex suffix,
|
||||
// so the stored filename is derived from the original — not always literally
|
||||
// "logo-<hex>". We only assert the publicUrl shape and that it is fetchable.
|
||||
expect(res.body.publicUrl).toMatch(/^\/uploads\/[a-z0-9._-]+\.png$/);
|
||||
|
||||
// The file was actually written under UPLOAD_DIR.
|
||||
const fileName = res.body.publicUrl.replace('/uploads/', '');
|
||||
const finalPath = path.join(process.env.UPLOAD_DIR!, fileName);
|
||||
expect(fs.existsSync(finalPath)).toBe(true);
|
||||
expect(fs.readFileSync(finalPath).length).toBeGreaterThan(0);
|
||||
|
||||
// And it is publicly fetchable via the static middleware.
|
||||
await request(app.getHttpServer()).get(res.body.publicUrl).expect(200);
|
||||
|
||||
// Clean up the file we created so the suite remains hermetic.
|
||||
fs.unlinkSync(finalPath);
|
||||
});
|
||||
|
||||
it('rejects oversize payloads (limit=1mb, body=2mb)', async () => {
|
||||
const { agent, csrf } = await primeCsrf();
|
||||
const big = Buffer.alloc(2 * 1024 * 1024, 0x61); // 2 MB of 'a'
|
||||
await agent
|
||||
.post('/api/v1/uploads/logo')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.attach('file', big, 'big.png')
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { deriveEventState, endAfterStartValidator, toDatetimeLocal, toIsoUtc } from '../../frontend/src/app/features/admin/general.pure';
|
||||
|
||||
describe('deriveEventState', () => {
|
||||
const start = '2026-01-01T00:00:00Z';
|
||||
const end = '2026-01-02T00:00:00Z';
|
||||
|
||||
it('returns "unconfigured" when start or end is missing', () => {
|
||||
expect(deriveEventState('', '')).toBe('unconfigured');
|
||||
expect(deriveEventState(start, '')).toBe('unconfigured');
|
||||
expect(deriveEventState('', end)).toBe('unconfigured');
|
||||
});
|
||||
|
||||
it('returns "unconfigured" when dates cannot be parsed', () => {
|
||||
expect(deriveEventState('not-a-date', end)).toBe('unconfigured');
|
||||
});
|
||||
|
||||
it('returns "countdown" when now is before start', () => {
|
||||
const s = new Date(Date.now() + 60_000).toISOString();
|
||||
const e = new Date(Date.now() + 120_000).toISOString();
|
||||
expect(deriveEventState(s, e)).toBe('countdown');
|
||||
});
|
||||
|
||||
it('returns "running" when now is between start and end', () => {
|
||||
const s = new Date(Date.now() - 60_000).toISOString();
|
||||
const e = new Date(Date.now() + 60_000).toISOString();
|
||||
expect(deriveEventState(s, e)).toBe('running');
|
||||
});
|
||||
|
||||
it('returns "stopped" when now is after end', () => {
|
||||
const s = new Date(Date.now() - 120_000).toISOString();
|
||||
const e = new Date(Date.now() - 60_000).toISOString();
|
||||
expect(deriveEventState(s, e)).toBe('stopped');
|
||||
});
|
||||
});
|
||||
|
||||
describe('endAfterStartValidator', () => {
|
||||
const ctrl = (start: string, end: string) => ({
|
||||
get: (k: string) =>
|
||||
k === 'eventStartUtc'
|
||||
? { value: start }
|
||||
: k === 'eventEndUtc'
|
||||
? { value: end }
|
||||
: { value: '' },
|
||||
});
|
||||
|
||||
it('returns null when either field is empty', () => {
|
||||
expect(endAfterStartValidator(ctrl('', '2026-01-01T00:00:00Z'))).toBeNull();
|
||||
expect(endAfterStartValidator(ctrl('2026-01-01T00:00:00Z', ''))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when end is strictly after start', () => {
|
||||
expect(endAfterStartValidator(ctrl('2026-01-01T00:00:00Z', '2026-01-02T00:00:00Z'))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns { endBeforeStart: true } when end equals start', () => {
|
||||
expect(endAfterStartValidator(ctrl('2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z'))).toEqual({ endBeforeStart: true });
|
||||
});
|
||||
|
||||
it('returns { endBeforeStart: true } when end is before start', () => {
|
||||
expect(endAfterStartValidator(ctrl('2026-01-02T00:00:00Z', '2026-01-01T00:00:00Z'))).toEqual({ endBeforeStart: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('datetime helpers', () => {
|
||||
it('toDatetimeLocal formats an ISO timestamp with UTC components', () => {
|
||||
expect(toDatetimeLocal('2026-01-02T03:04:00Z')).toBe('2026-01-02T03:04');
|
||||
});
|
||||
|
||||
it('toDatetimeLocal returns empty string for invalid input', () => {
|
||||
expect(toDatetimeLocal('')).toBe('');
|
||||
expect(toDatetimeLocal('garbage')).toBe('');
|
||||
});
|
||||
|
||||
it('toIsoUtc converts a local datetime back to UTC ISO', () => {
|
||||
const iso = toIsoUtc('2026-01-02T03:04');
|
||||
expect(iso).toMatch(/^2026-01-02T03:04:00\.000Z$/);
|
||||
});
|
||||
|
||||
it('toIsoUtc returns input verbatim when empty', () => {
|
||||
expect(toIsoUtc('')).toBe('');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user