Files
HIPCTF2/tests/backend/admin-general-service.spec.ts
T
2026-07-22 15:47:02 +00:00

299 lines
10 KiB
TypeScript

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);
});
it('rejects a whitespace-only pageTitle', () => {
const r = GeneralSettingsSchema.safeParse({
pageTitle: ' ',
logo: '',
welcomeMarkdown: '',
themeKey: 'classic',
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('trims surrounding whitespace from a valid pageTitle', () => {
const r = GeneralSettingsSchema.safeParse({
pageTitle: ' OpenVelo ',
logo: '',
welcomeMarkdown: '',
themeKey: 'classic',
eventStartUtc: '2026-01-01T00:00:00Z',
eventEndUtc: '2026-02-01T00:00:00Z',
defaultChallengeIp: '127.0.0.1',
registrationsEnabled: false,
});
expect(r.success).toBe(true);
if (r.success) {
expect(r.data.pageTitle).toBe('OpenVelo');
}
});
it('rejects a non-datetime eventStartUtc ("not-a-date")', () => {
const r = GeneralSettingsSchema.safeParse({
pageTitle: 'T',
logo: '',
welcomeMarkdown: '',
themeKey: 'classic',
eventStartUtc: 'not-a-date',
eventEndUtc: '2026-02-01T00:00:00Z',
defaultChallengeIp: '127.0.0.1',
registrationsEnabled: false,
});
expect(r.success).toBe(false);
if (!r.success) {
const paths = r.error.issues.map((i) => i.path.join('.'));
expect(paths).toContain('eventStartUtc');
expect(r.error.issues.some((i) => /datetime/i.test(i.message))).toBe(true);
}
});
it('rejects a non-datetime eventEndUtc and reports the issue on eventEndUtc', () => {
const r = GeneralSettingsSchema.safeParse({
pageTitle: 'T',
logo: '',
welcomeMarkdown: '',
themeKey: 'classic',
eventStartUtc: '2026-08-15T10:30:00.000Z',
eventEndUtc: 'also-not-a-date',
defaultChallengeIp: '127.0.0.1',
registrationsEnabled: false,
});
expect(r.success).toBe(false);
if (!r.success) {
const paths = r.error.issues.map((i) => i.path.join('.'));
expect(paths).toContain('eventEndUtc');
expect(paths).not.toContain('eventStartUtc');
expect(r.error.issues.some((i) => /datetime/i.test(i.message))).toBe(true);
}
});
it('rejects both fields as non-datetime strings (the Job negative case)', () => {
const r = GeneralSettingsSchema.safeParse({
pageTitle: 'T',
logo: '',
welcomeMarkdown: '',
themeKey: 'classic',
eventStartUtc: 'not-a-date',
eventEndUtc: 'also-not-a-date',
defaultChallengeIp: '127.0.0.1',
registrationsEnabled: false,
});
expect(r.success).toBe(false);
});
it('rejects empty strings for event datetime fields with per-field messages (Job 888)', () => {
const r = GeneralSettingsSchema.safeParse({
pageTitle: 'T',
logo: '',
welcomeMarkdown: '',
themeKey: 'classic',
eventStartUtc: '',
eventEndUtc: '',
defaultChallengeIp: '127.0.0.1',
registrationsEnabled: false,
});
expect(r.success).toBe(false);
if (r.success) return;
const startIssue = r.error.issues.find((i) => i.path.join('.') === 'eventStartUtc');
const endIssue = r.error.issues.find((i) => i.path.join('.') === 'eventEndUtc');
expect(startIssue?.message).toBe('eventStartUtc must be a valid ISO-8601 datetime');
expect(endIssue?.message).toBe('eventEndUtc must be a valid ISO-8601 datetime');
});
});
describe('GeneralSettingsSchema - defaultChallengeIp (Job 891)', () => {
const base = {
pageTitle: 'T',
logo: '',
welcomeMarkdown: '',
themeKey: 'classic',
eventStartUtc: '2026-01-01T00:00:00Z',
eventEndUtc: '2026-02-01T00:00:00Z',
registrationsEnabled: false,
};
it('accepts a valid IPv4 address', () => {
const r = GeneralSettingsSchema.safeParse({ ...base, defaultChallengeIp: '10.66.77.88' });
expect(r.success).toBe(true);
if (r.success) expect(r.data.defaultChallengeIp).toBe('10.66.77.88');
});
it('accepts a valid hostname', () => {
const r = GeneralSettingsSchema.safeParse({ ...base, defaultChallengeIp: 'challenge.example.com' });
expect(r.success).toBe(true);
if (r.success) expect(r.data.defaultChallengeIp).toBe('challenge.example.com');
});
it('trims surrounding whitespace from a valid value', () => {
const r = GeneralSettingsSchema.safeParse({ ...base, defaultChallengeIp: ' 10.20.30.40 ' });
expect(r.success).toBe(true);
if (r.success) expect(r.data.defaultChallengeIp).toBe('10.20.30.40');
});
it('rejects an empty string', () => {
const r = GeneralSettingsSchema.safeParse({ ...base, defaultChallengeIp: '' });
expect(r.success).toBe(false);
if (r.success) return;
expect(r.error.issues.some((i) => i.path.join('.') === 'defaultChallengeIp')).toBe(true);
expect(r.error.issues.some((i) => /required/i.test(i.message))).toBe(true);
});
it('rejects a whitespace-only value', () => {
const r = GeneralSettingsSchema.safeParse({ ...base, defaultChallengeIp: ' ' });
expect(r.success).toBe(false);
if (r.success) return;
expect(r.error.issues.some((i) => i.path.join('.') === 'defaultChallengeIp')).toBe(true);
});
it('rejects incomplete IPv4 forms', () => {
const r1 = GeneralSettingsSchema.safeParse({ ...base, defaultChallengeIp: '10.0.0.' });
const r2 = GeneralSettingsSchema.safeParse({ ...base, defaultChallengeIp: '10.0.0' });
expect(r1.success).toBe(false);
expect(r2.success).toBe(false);
});
it('rejects malformed addresses with the format message', () => {
const r = GeneralSettingsSchema.safeParse({ ...base, defaultChallengeIp: '999.1.1.1' });
expect(r.success).toBe(false);
if (r.success) return;
expect(r.error.issues.some((i) => /IPv4/.test(i.message) || /hostname/.test(i.message))).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);
});
});