initial commit

This commit is contained in:
SowinskiBraeden committed 2026-07-05 16:54:59 -07:00
commit ce8f719a05
106 files changed
+24584

No files matched your search

+426
View File
@@ -0,0 +1,426 @@
import { describe, expect, it } from 'vitest';
import request from 'supertest';
import type { Role } from '@reforger-panel/shared';
import { createApp } from '../src/app.js';
import { loadEnv } from '../src/env.js';
import { createLogger } from '../src/lib/logger.js';
import type { Db } from '../src/db/client.js';
import {
resolveRoleForLogin,
type SessionService,
type SessionUser,
} from '../src/modules/auth/session-service.js';
import type { ServerModsService } from '../src/modules/config/mods-service.js';
import type { PerformanceSettingsService } from '../src/modules/config/performance-service.js';
import type { ResourceHistoryService } from '../src/modules/servers/resource-history.js';
import { MockGameServerProvider } from '../src/modules/pterodactyl/mock-provider.js';
import type { IngestionScheduler } from '../src/modules/reforger-logs/ingestion/scheduler.js';
import type { ServerRecord, ServerService } from '../src/modules/servers/server-service.js';
import { WorkshopClient } from '../src/modules/workshop/workshop-client.js';
const OWNER_ID = '111111111111111111';
const TEST_ENV = {
NODE_ENV: 'test',
DATABASE_URL: 'postgresql://unused',
SESSION_SECRET: 'a'.repeat(40),
OWNER_DISCORD_ID: OWNER_ID,
USE_MOCK_PTERODACTYL: 'true',
};
function makeUser(role: Role): SessionUser {
return {
id: `user-${role}`,
discordId: `discord-${role}`,
username: role,
displayName: role,
avatarUrl: null,
role,
};
}
const TOKENS: Record<string, SessionUser> = {
'owner-token': makeUser('owner'),
'admin-token': makeUser('server_admin'),
'lead-token': makeUser('mission_lead'),
'viewer-token': makeUser('viewer'),
};
const trainingServer: ServerRecord = {
id: 'srv-1',
slug: 'training-server',
name: 'Training Server',
providerType: 'pterodactyl',
pterodactylServerId: null,
status: 'online',
maxPlayers: 20,
createdAt: new Date(),
updatedAt: new Date(),
};
function buildApp() {
const env = loadEnv(TEST_ENV as NodeJS.ProcessEnv);
const provider = new MockGameServerProvider();
const activity: { action: string; actorUserId: string | null }[] = [];
const sessions = {
getUserBySessionToken: async (token: string) => TOKENS[token] ?? null,
revokeSession: async () => undefined,
} as unknown as SessionService;
const servers = {
getServerBySlug: async (slug: string) => (slug === 'training-server' ? trainingServer : null),
listServers: async () => [trainingServer],
countOnlinePlayers: async () => 0,
updateStatus: async () => undefined,
recordActivity: async (input: { action: string; actorUserId: string | null }) => {
activity.push(input);
},
getOnlinePlayers: async () => ({
players: [],
onlineCount: 0,
maxPlayers: 20,
lastSyncedAt: null,
stale: true,
}),
getActivity: async () => [],
getConfiguration: async () => ({ current: null, history: [] }),
getModPacks: async () => [],
getKnownPlayers: async () => [],
getLogCursor: async () => null,
} as unknown as ServerService;
const scheduler = {
syncNow: async () => ({
serverId: 'srv-1',
logPath: '/profile/logs/console.log',
fetchedBytes: 0,
processedLines: 0,
createdEvents: 0,
updatedSessions: 0,
cursorReset: false,
startedAt: new Date().toISOString(),
finishedAt: new Date().toISOString(),
ignoredLines: 0,
invalidTimestamps: 0,
reason: 'no_new_data',
}),
getLastResult: () => null,
} as unknown as IngestionScheduler;
const app = createApp({
env,
logger: createLogger('silent'),
db: {} as Db,
sessions,
servers,
provider,
workshop: new WorkshopClient({ baseUrl: 'https://workshop.invalid' }),
scheduler,
resolveLogPath: async () => '/profile/logs/console.log',
configSync: null,
mods: {
getMods: async () => ({ mods: [], fetchedAt: new Date().toISOString() }),
setMods: async () => ({
mods: [],
fetchedAt: new Date().toISOString(),
added: 0,
removed: 0,
requiresRestart: true as const,
}),
} as unknown as ServerModsService,
performance: {
get: async () => ({ settings: {}, fetchedAt: new Date().toISOString() }),
update: async (_server: unknown, settings: unknown) => ({
settings,
fetchedAt: new Date().toISOString(),
changedFields: [],
requiresRestart: true as const,
}),
} as unknown as PerformanceSettingsService,
resourceHistory: {
history: () => ({ samples: [], intervalSeconds: 15 }),
} as unknown as ResourceHistoryService,
missions: null,
});
return { app, provider, activity };
}
function asUser(token: string) {
return { Cookie: `rp_session=${token}`, 'X-CSRF-Protection': '1' };
}
describe('authentication and roles', () => {
it('rejects unauthenticated requests to /api/auth/me', async () => {
const { app } = buildApp();
const response = await request(app).get('/api/auth/me');
expect(response.status).toBe(401);
expect(response.body.error.code).toBe('UNAUTHENTICATED');
});
it('returns the current user with capabilities', async () => {
const { app } = buildApp();
const response = await request(app).get('/api/auth/me').set(asUser('viewer-token'));
expect(response.status).toBe(200);
expect(response.body.role).toBe('viewer');
expect(response.body.capabilities).toEqual(['server.view']);
});
it('bootstraps the owner role from OWNER_DISCORD_ID and defaults others to viewer', () => {
expect(resolveRoleForLogin(null, OWNER_ID, OWNER_ID)).toBe('owner');
expect(resolveRoleForLogin('viewer', OWNER_ID, OWNER_ID)).toBe('owner');
expect(resolveRoleForLogin(null, '222', OWNER_ID)).toBe('viewer');
expect(resolveRoleForLogin('server_admin', '222', OWNER_ID)).toBe('server_admin');
// No owner configured: nobody is silently promoted.
expect(resolveRoleForLogin(null, '', '')).toBe('viewer');
});
it('requires authentication on server routes', async () => {
const { app } = buildApp();
const response = await request(app).get('/api/servers');
expect(response.status).toBe(401);
});
});
describe('power controls by role', () => {
const cases: { token: string; action: string; expected: number }[] = [
{ token: 'owner-token', action: 'start', expected: 200 },
{ token: 'owner-token', action: 'stop', expected: 200 },
{ token: 'owner-token', action: 'restart', expected: 200 },
{ token: 'admin-token', action: 'start', expected: 200 },
{ token: 'admin-token', action: 'stop', expected: 200 },
{ token: 'admin-token', action: 'restart', expected: 200 },
{ token: 'lead-token', action: 'restart', expected: 200 },
{ token: 'lead-token', action: 'start', expected: 403 },
{ token: 'lead-token', action: 'stop', expected: 403 },
{ token: 'viewer-token', action: 'start', expected: 403 },
{ token: 'viewer-token', action: 'stop', expected: 403 },
{ token: 'viewer-token', action: 'restart', expected: 403 },
];
for (const { token, action, expected } of cases) {
it(`${token.replace('-token', '')} ${action}${expected}`, async () => {
const { app } = buildApp();
const response = await request(app)
.post(`/api/servers/training-server/power/${action}`)
.set(asUser(token));
expect(response.status).toBe(expected);
if (expected === 403) {
expect(response.body.error.code).toBe('FORBIDDEN');
}
});
}
it('simulated power actions still create activity records', async () => {
const { app, activity } = buildApp();
await request(app)
.post('/api/servers/training-server/power/restart')
.set(asUser('lead-token'))
.expect(200);
expect(activity).toHaveLength(1);
expect(activity[0]!.action).toBe('server.power.restart');
});
});
describe('manual log sync and diagnostics', () => {
it('allows only the owner to trigger a manual sync', async () => {
const { app } = buildApp();
await request(app)
.post('/api/servers/training-server/logs/sync')
.set(asUser('owner-token'))
.expect(200);
for (const token of ['admin-token', 'lead-token', 'viewer-token']) {
const response = await request(app)
.post('/api/servers/training-server/logs/sync')
.set(asUser(token));
expect(response.status).toBe(403);
}
});
it('hides log ingestion health from mission leads and viewers', async () => {
const { app } = buildApp();
await request(app)
.get('/api/servers/training-server/logs/health')
.set(asUser('admin-token'))
.expect(200);
await request(app)
.get('/api/servers/training-server/logs/health')
.set(asUser('lead-token'))
.expect(403);
});
it('restricts user management to the owner', async () => {
const { app } = buildApp();
const response = await request(app).get('/api/users').set(asUser('viewer-token'));
expect(response.status).toBe(403);
});
});
describe('mod management by role', () => {
it('allows owner and server_admin to update mods', async () => {
const { app } = buildApp();
for (const token of ['owner-token', 'admin-token']) {
const response = await request(app)
.put('/api/servers/training-server/mods')
.set(asUser(token))
.send({ mods: [{ modId: '591AF5BDA9F7CE8B', name: 'X' }] });
expect(response.status).toBe(200);
}
});
it('forbids mission leads and viewers from updating mods', async () => {
const { app } = buildApp();
for (const token of ['lead-token', 'viewer-token']) {
const response = await request(app)
.put('/api/servers/training-server/mods')
.set(asUser(token))
.send({ mods: [] });
expect(response.status).toBe(403);
}
});
it('rejects invalid mod ids and duplicates', async () => {
const { app } = buildApp();
const bad = await request(app)
.put('/api/servers/training-server/mods')
.set(asUser('owner-token'))
.send({ mods: [{ modId: 'not-a-mod-id' }] });
expect(bad.status).toBe(400);
const dup = await request(app)
.put('/api/servers/training-server/mods')
.set(asUser('owner-token'))
.send({
mods: [{ modId: '591AF5BDA9F7CE8B' }, { modId: '591af5bda9f7ce8b' }],
});
expect(dup.status).toBe(400);
});
});
describe('schedule management by role', () => {
it('allows owner and server_admin to view schedules', async () => {
const { app } = buildApp();
await request(app)
.get('/api/servers/training-server/schedules')
.set(asUser('owner-token'))
.expect(200);
await request(app)
.get('/api/servers/training-server/schedules')
.set(asUser('admin-token'))
.expect(200);
await request(app)
.get('/api/servers/training-server/schedules')
.set(asUser('lead-token'))
.expect(403);
});
it('creates restart schedules through the provider and records activity', async () => {
const { app, activity } = buildApp();
const response = await request(app)
.post('/api/servers/training-server/schedules/restarts')
.set(asUser('admin-token'))
.send({
name: 'Morning restart',
isActive: true,
minute: 30,
hour: 8,
dayOfWeek: '*',
onlyWhenOnline: true,
});
expect(response.status).toBe(200);
expect(response.body.schedule.name).toBe('Morning restart');
expect(response.body.schedule.tasks[0].payload).toBe('restart');
expect(activity.at(-1)?.action).toBe('schedule.restart.created');
});
});
describe('performance config by role', () => {
const validBody = {
maxPlayers: 32,
serverMaxViewDistance: null,
networkViewDistance: null,
serverMinGrassDistance: null,
disableThirdPerson: null,
fastValidation: null,
battlEye: null,
aiLimit: null,
playerSaveTime: null,
slotReservationTimeout: null,
lobbyPlayerSynchronise: null,
};
it('allows owner and server_admin, forbids mission_lead and viewer', async () => {
const { app } = buildApp();
for (const token of ['owner-token', 'admin-token']) {
await request(app)
.put('/api/servers/training-server/config/performance')
.set(asUser(token))
.send(validBody)
.expect(200);
}
for (const token of ['lead-token', 'viewer-token']) {
await request(app)
.put('/api/servers/training-server/config/performance')
.set(asUser(token))
.send(validBody)
.expect(403);
}
});
it('rejects out-of-range values with the offending field named', async () => {
const { app } = buildApp();
const response = await request(app)
.put('/api/servers/training-server/config/performance')
.set(asUser('owner-token'))
.send({ ...validBody, serverMaxViewDistance: 99999 });
expect(response.status).toBe(400);
expect(response.body.error.message).toContain('serverMaxViewDistance');
});
it('serves resource history to any authenticated user', async () => {
const { app } = buildApp();
const response = await request(app)
.get('/api/servers/training-server/resources/history')
.set(asUser('viewer-token'));
expect(response.status).toBe(200);
expect(response.body.intervalSeconds).toBe(15);
});
});
describe('invites', () => {
it('restricts invite management to the owner', async () => {
const { app } = buildApp();
for (const token of ['admin-token', 'lead-token', 'viewer-token']) {
const response = await request(app).get('/api/invites').set(asUser(token));
expect(response.status).toBe(403);
}
});
it('rejects malformed redeem codes before touching the database', async () => {
const { app } = buildApp();
const response = await request(app)
.post('/api/invites/redeem')
.set(asUser('viewer-token'))
.send({ code: '' });
expect(response.status).toBe(400);
});
});
describe('CSRF protection', () => {
it('rejects state-changing requests without the CSRF header', async () => {
const { app } = buildApp();
const response = await request(app)
.post('/api/servers/training-server/power/restart')
.set('Cookie', 'rp_session=owner-token');
expect(response.status).toBe(403);
});
it('rejects cross-origin state-changing requests', async () => {
const { app } = buildApp();
const response = await request(app)
.post('/api/servers/training-server/power/restart')
.set(asUser('owner-token'))
.set('Origin', 'https://evil.example.com');
expect(response.status).toBe(403);
});
});
@@ -0,0 +1,165 @@
import { randomUUID } from 'node:crypto';
import type {
CursorRecord,
IngestionStore,
LogSource,
NewServerEvent,
OpenSessionRecord,
PlayerRecord,
} from '../../src/modules/reforger-logs/ingestion/types.js';
import type { DownloadableFile } from '../../src/modules/pterodactyl/types.js';
export type StoredSession = {
id: string;
serverId: string;
playerId: string;
connectedAt: Date;
disconnectedAt: Date | null;
durationSeconds: number | null;
disconnectReason: string | null;
sourceLogPath: string;
};
export type StoredEvent = NewServerEvent & { id: string };
export class InMemoryIngestionStore implements IngestionStore {
cursors = new Map<string, CursorRecord>();
events: StoredEvent[] = [];
players: (PlayerRecord & { firstSeenAt: Date; lastSeenAt: Date })[] = [];
sessions: StoredSession[] = [];
async getCursor(serverId: string, logPath: string) {
return this.cursors.get(`${serverId}:${logPath}`) ?? null;
}
async saveCursor(cursor: CursorRecord) {
this.cursors.set(`${cursor.serverId}:${cursor.logPath}`, { ...cursor });
}
async insertEventIfNew(event: NewServerEvent) {
const duplicate = this.events.find(
(e) =>
e.serverId === event.serverId &&
e.sourceLogPath === event.sourceLogPath &&
e.sourceLineHash === event.sourceLineHash,
);
if (duplicate) return { created: false, eventId: null };
const stored = { ...event, id: randomUUID() };
this.events.push(stored);
return { created: true, eventId: stored.id };
}
async findPlayerByExternalId(serverId: string, externalPlayerId: string) {
return (
this.players.find(
(p) => p.serverId === serverId && p.externalPlayerId === externalPlayerId,
) ?? null
);
}
async findPlayerByName(serverId: string, displayName: string) {
return (
this.players.find((p) => p.serverId === serverId && p.displayName === displayName) ?? null
);
}
async createPlayer(input: {
serverId: string;
displayName: string;
externalPlayerId: string | null;
seenAt: Date;
}) {
const player = {
id: randomUUID(),
serverId: input.serverId,
displayName: input.displayName,
externalPlayerId: input.externalPlayerId,
firstSeenAt: input.seenAt,
lastSeenAt: input.seenAt,
};
this.players.push(player);
return player;
}
async updatePlayer(
playerId: string,
patch: { externalPlayerId?: string; displayName?: string; lastSeenAt?: Date },
) {
const player = this.players.find((p) => p.id === playerId);
if (player) Object.assign(player, patch);
}
async getOpenSession(serverId: string, playerId: string): Promise<OpenSessionRecord | null> {
const session = this.sessions.find(
(s) => s.serverId === serverId && s.playerId === playerId && s.disconnectedAt === null,
);
return session
? { id: session.id, playerId: session.playerId, connectedAt: session.connectedAt }
: null;
}
async openSession(input: {
serverId: string;
playerId: string;
connectedAt: Date;
sourceLogPath: string;
}) {
const session: StoredSession = {
id: randomUUID(),
serverId: input.serverId,
playerId: input.playerId,
connectedAt: input.connectedAt,
disconnectedAt: null,
durationSeconds: null,
disconnectReason: null,
sourceLogPath: input.sourceLogPath,
};
this.sessions.push(session);
return { id: session.id, playerId: session.playerId, connectedAt: session.connectedAt };
}
async closeSession(
sessionId: string,
input: { disconnectedAt: Date; durationSeconds: number; disconnectReason: string | null },
) {
const session = this.sessions.find((s) => s.id === sessionId);
if (session) Object.assign(session, input);
}
async closeAllOpenSessions(serverId: string, disconnectedAt: Date, reason: string) {
const open = this.sessions.filter((s) => s.serverId === serverId && s.disconnectedAt === null);
for (const session of open) {
session.disconnectedAt = disconnectedAt;
session.durationSeconds = Math.max(
0,
Math.round((disconnectedAt.getTime() - session.connectedAt.getTime()) / 1000),
);
session.disconnectReason = reason;
}
return { closed: open.length };
}
openSessions(serverId: string) {
return this.sessions.filter((s) => s.serverId === serverId && s.disconnectedAt === null);
}
}
/** LogSource whose content can be mutated between syncs to simulate a live file. */
export class FakeLogSource implements LogSource {
content = '';
failWith: Error | null = null;
async fetchLog(_serverId: string, logPath: string, maxBytes: number): Promise<DownloadableFile> {
if (this.failWith) throw this.failWith;
const buffer = Buffer.from(this.content, 'utf8');
const trimmed =
buffer.byteLength > maxBytes ? buffer.subarray(buffer.byteLength - maxBytes) : buffer;
return {
path: logPath,
content: trimmed.toString('utf8'),
totalSizeBytes: buffer.byteLength,
contentStartOffset: buffer.byteLength - trimmed.byteLength,
truncated: trimmed.byteLength < buffer.byteLength,
};
}
}
+229
View File
@@ -0,0 +1,229 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { LogIngestionService } from '../src/modules/reforger-logs/ingestion/ingestion-service.js';
import { createLogger } from '../src/lib/logger.js';
import { FakeLogSource, InMemoryIngestionStore } from './helpers/in-memory-ingestion-store.js';
const SERVER_ID = 'srv-1';
const PROVIDER_ID = 'ptero-1';
const LOG_PATH = '/profile/logs/console.log';
const HEADER = 'Log started 2026-07-04 10:00:00\n';
function connect(time: string, num: number, name: string): string {
return `${time} DEFAULT : BattlEye Server: 'Player #${num} ${name} (10.0.0.${num}:5000) connected'\n`;
}
function guid(time: string, num: number, name: string, id: string): string {
return `${time} DEFAULT : BattlEye Server: 'Player #${num} ${name} - GUID: ${id}'\n`;
}
function disconnect(time: string, num: number, name: string): string {
return `${time} DEFAULT : BattlEye Server: 'Player #${num} ${name} disconnected'\n`;
}
function kill(time: string, victim: string, killer: string, friendly = false): string {
return `${time} SCRIPT : ServerAdminTools | Event serveradmintools_player_killed | player: ${victim}, instigator: ${killer}, friendly: ${friendly ? 'true' : 'false'}\n`;
}
describe('LogIngestionService', () => {
let store: InMemoryIngestionStore;
let source: FakeLogSource;
let service: LogIngestionService;
beforeEach(() => {
store = new InMemoryIngestionStore();
source = new FakeLogSource();
service = new LogIngestionService(source, store, createLogger('silent'), {
maxDownloadBytes: 2 * 1024 * 1024,
});
});
const sync = () => service.sync(SERVER_ID, PROVIDER_ID, LOG_PATH);
it('creates players, events, and open sessions from connect lines', async () => {
source.content =
HEADER +
connect('10:05:00.000', 1, 'Braeden') +
guid('10:05:01.000', 1, 'Braeden', 'aabbccdd11223344');
const result = await sync();
expect(result.createdEvents).toBe(1);
expect(store.players).toHaveLength(1);
expect(store.players[0]!.externalPlayerId).toBe('aabbccdd11223344');
expect(store.openSessions(SERVER_ID)).toHaveLength(1);
expect(result.cursorReset).toBe(false);
});
it('closes sessions with duration on disconnect', async () => {
source.content =
HEADER + connect('10:00:10.000', 1, 'Braeden') + disconnect('10:42:10.000', 1, 'Braeden');
await sync();
expect(store.openSessions(SERVER_ID)).toHaveLength(0);
const session = store.sessions[0]!;
expect(session.durationSeconds).toBe(42 * 60);
});
it('tracks multiple simultaneous players', async () => {
source.content =
HEADER +
connect('10:01:00.000', 1, 'Alpha') +
connect('10:02:00.000', 2, 'Bravo') +
connect('10:03:00.000', 3, 'Charlie') +
disconnect('10:30:00.000', 2, 'Bravo');
await sync();
expect(store.players).toHaveLength(3);
const open = store.openSessions(SERVER_ID);
expect(open).toHaveLength(2);
const openNames = open.map((s) => store.players.find((p) => p.id === s.playerId)!.displayName);
expect(openNames.sort()).toEqual(['Alpha', 'Charlie']);
});
it('stores ServerAdminTools killfeed events', async () => {
source.content = HEADER + kill('10:10:00.000', 'Victim', 'Killer');
const result = await sync();
expect(result.createdEvents).toBe(1);
expect(store.events[0]!.eventType).toBe('player_killed');
expect(store.events[0]!.payload).toMatchObject({
killerName: 'Killer',
victimName: 'Victim',
friendly: false,
distanceMeters: null,
weapon: null,
});
expect(store.players.map((player) => player.displayName).sort()).toEqual(['Killer', 'Victim']);
});
it('does not duplicate events when the same content is synced twice', async () => {
source.content = HEADER + connect('10:05:00.000', 1, 'Braeden');
await sync();
// Force a cursor reset by clearing the cursor: same lines get re-read.
store.cursors.clear();
const second = await sync();
expect(second.createdEvents).toBe(0);
expect(store.events).toHaveLength(1);
expect(store.openSessions(SERVER_ID)).toHaveLength(1);
});
it('processes only appended content on subsequent syncs', async () => {
source.content = HEADER + connect('10:05:00.000', 1, 'Braeden');
const first = await sync();
source.content += disconnect('10:45:00.000', 1, 'Braeden');
const second = await sync();
expect(first.createdEvents).toBe(1);
expect(second.createdEvents).toBe(1);
expect(second.cursorReset).toBe(false);
expect(second.processedLines).toBe(1);
expect(store.openSessions(SERVER_ID)).toHaveLength(0);
});
it('carries a partial trailing line across syncs and parses it once complete', async () => {
const full = connect('10:05:00.000', 1, 'Braeden');
source.content = HEADER + full.slice(0, 40); // mid-line
const first = await sync();
expect(first.createdEvents).toBe(0);
source.content = HEADER + full;
const second = await sync();
expect(second.createdEvents).toBe(1);
expect(store.events[0]!.eventType).toBe('player_connected');
});
it('handles log rotation: resets the cursor and ingests the new file without duplicates', async () => {
source.content =
HEADER + connect('10:05:00.000', 1, 'Braeden') + disconnect('11:00:00.000', 1, 'Braeden');
await sync();
expect(store.events).toHaveLength(2);
// Rotation: much smaller replacement file with a fresh header.
source.content = 'Log started 2026-07-04 11:30:00\n' + connect('11:31:00.000', 1, 'Sable');
const afterRotation = await sync();
expect(afterRotation.cursorReset).toBe(true);
expect(afterRotation.createdEvents).toBe(1);
expect(store.events).toHaveLength(3);
});
it('bounds the first sync of a huge file instead of importing full history', async () => {
let old = HEADER;
for (let i = 0; i < 30_000; i += 1) {
old += `09:00:00.000 SCRIPT : filler line ${i} ${'x'.repeat(20)}\n`;
}
source.content = old + connect('10:05:00.000', 1, 'Braeden');
const result = await sync();
expect(result.createdEvents).toBe(1);
expect(result.processedLines).toBeLessThan(30_000);
});
it('closes orphaned sessions when a server start is detected', async () => {
source.content = HEADER + connect('10:05:00.000', 1, 'Braeden');
await sync();
expect(store.openSessions(SERVER_ID)).toHaveLength(1);
source.content += '11:00:00.000 DEFAULT : Game successfully created.\n';
const result = await sync();
expect(store.openSessions(SERVER_ID)).toHaveLength(0);
expect(store.sessions[0]!.disconnectReason).toBe('server_restart');
expect(store.events.map((e) => e.eventType)).toContain('server_restart_detected');
expect(result.updatedSessions).toBe(1);
});
it('closes a stale session when the same player reconnects without a disconnect', async () => {
source.content = HEADER + connect('10:05:00.000', 1, 'Braeden');
await sync();
source.content += connect('12:00:00.000', 4, 'Braeden');
await sync();
const open = store.openSessions(SERVER_ID);
expect(open).toHaveLength(1);
expect(open[0]!.connectedAt.toISOString()).toBe('2026-07-04T12:00:00.000Z');
const closed = store.sessions.find((s) => s.disconnectedAt !== null)!;
expect(closed.disconnectReason).toBe('missed_disconnect');
});
it('records a sanitized error on the cursor when the download fails', async () => {
source.failWith = new Error('connect ETIMEDOUT 10.1.2.3:443 with apiKey=secret123');
await expect(sync()).rejects.toThrow();
const cursor = await store.getCursor(SERVER_ID, LOG_PATH);
expect(cursor?.lastErrorAt).toBeInstanceOf(Date);
expect(cursor?.lastErrorMessage).not.toContain('secret123');
expect(cursor?.lastSuccessfulSyncAt).toBeNull();
// Recovery: the next successful sync clears the error.
source.failWith = null;
source.content = HEADER + connect('10:05:00.000', 1, 'Braeden');
await sync();
const recovered = await store.getCursor(SERVER_ID, LOG_PATH);
expect(recovered?.lastErrorAt).toBeNull();
expect(recovered?.lastSuccessfulSyncAt).toBeInstanceOf(Date);
});
it('keeps one player when the logs emit both identityId and BattlEye GUID', async () => {
// Real logs emit BACKEND Authenticated (uuid) then the BE GUID line.
source.content =
HEADER +
'10:04:59.941 BACKEND : Authenticated player: rplIdentity=0x00000000 identityId=33cd5666-3466-477c-aeb8-010df1978756 name=Braeden\n' +
connect('10:05:00.000', 1, 'Braeden') +
guid('10:05:01.000', 1, 'Braeden', '8f1ec46b6979b3a3590e62aa8b757a68');
await sync();
expect(store.players).toHaveLength(1);
// First identity wins; the GUID does not split the player.
expect(store.players[0]!.externalPlayerId).toBe('33cd5666-3466-477c-aeb8-010df1978756');
expect(store.openSessions(SERVER_ID)).toHaveLength(1);
});
it('merges identity lines that arrive in a later sync via open-session name match', async () => {
source.content = HEADER + connect('10:05:00.000', 1, 'Braeden');
await sync();
source.content += guid('10:05:02.000', 1, 'Braeden', 'deadbeef00000001');
await sync();
expect(store.players).toHaveLength(1);
expect(store.players[0]!.externalPlayerId).toBe('deadbeef00000001');
});
});
+108
View File
@@ -0,0 +1,108 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MockGameServerProvider } from '../src/modules/pterodactyl/mock-provider.js';
import { ConfigFileGateway } from '../src/modules/config/config-file-gateway.js';
import { ServerModsService } from '../src/modules/config/mods-service.js';
import type { ConfigSyncService } from '../src/modules/config/config-sync.js';
import type { ServerRecord } from '../src/modules/servers/server-service.js';
import { createLogger } from '../src/lib/logger.js';
import { ApiError } from '../src/lib/errors.js';
const server: ServerRecord = {
id: 'srv-1',
slug: 'training-server',
name: 'SCAR Operations',
providerType: 'pterodactyl',
pterodactylServerId: 'abc123',
status: 'online',
maxPlayers: 16,
createdAt: new Date(),
updatedAt: new Date(),
};
describe('ServerModsService', () => {
let provider: MockGameServerProvider;
let service: ServerModsService;
let configSyncCalled: number;
beforeEach(() => {
provider = new MockGameServerProvider();
configSyncCalled = 0;
const configSync = {
sync: async () => {
configSyncCalled += 1;
return { changed: true, revisionVersion: 2, serverName: 'x', maxPlayers: 16 };
},
} as unknown as ConfigSyncService;
service = new ServerModsService(
new ConfigFileGateway(provider, '/config.json'),
configSync,
createLogger('silent'),
);
});
it('reads the current mods from config.json', async () => {
const result = await service.getMods(server);
expect(result.mods).toEqual([
{ modId: '591AF5BDA9F7CE8B', name: 'Mock Sample Mod', version: '1.0.2' },
]);
});
it('writes the new mod list while preserving every other config field', async () => {
const result = await service.setMods(server, [
{ modId: '591AF5BDA9F7CE8B', name: 'Mock Sample Mod', version: '1.0.2' },
{ modId: '5AAF0CCE3F001FB5', name: 'Server Admin Tools' },
]);
expect(result.added).toBe(1);
expect(result.removed).toBe(0);
expect(result.requiresRestart).toBe(true);
expect(result.mods).toHaveLength(2);
const written = provider.writtenFiles.get('/config.json')!;
const parsed = JSON.parse(written);
// game.mods replaced…
expect(parsed.game.mods).toEqual([
{ modId: '591AF5BDA9F7CE8B', name: 'Mock Sample Mod', version: '1.0.2' },
{ modId: '5AAF0CCE3F001FB5', name: 'Server Admin Tools' },
]);
// …everything else untouched.
expect(parsed.bindPort).toBe(2001);
expect(parsed.game.name).toBe('Mock Reforger Server');
expect(parsed.game.maxPlayers).toBe(16);
expect(parsed.operating.aiLimit).toBe(40);
});
it('writes a rollback backup of the previous file before modifying it', async () => {
const before = (await provider.downloadTextFile('abc123', '/config.json')).content;
await service.setMods(server, []);
expect(provider.writtenFiles.get('/config.json.bak')).toBe(before);
// Removal reflected in the live file.
const after = JSON.parse(provider.writtenFiles.get('/config.json')!);
expect(after.game.mods).toEqual([]);
});
it('imports a config revision after a successful write', async () => {
await service.setMods(server, []);
expect(configSyncCalled).toBe(1);
});
it('normalizes mod ids to uppercase and drops empty name/version', async () => {
const result = await service.setMods(server, [{ modId: '69c566706abd5a3c', name: '' }]);
expect(result.mods).toEqual([{ modId: '69C566706ABD5A3C' }]);
});
it('fails the write when read-back verification does not match', async () => {
// Simulate a server that ignores writes to config.json.
const originalWrite = provider.writeTextFile.bind(provider);
vi.spyOn(provider, 'writeTextFile').mockImplementation(async (sid, path, content) => {
if (path === '/config.json') return; // swallow the write
await originalWrite(sid, path, content);
});
await expect(service.setMods(server, [])).rejects.toThrow(/verification failed/);
});
it('refuses to modify a config without a game section', async () => {
await provider.writeTextFile('abc123', '/config.json', '{"something": true}');
await expect(service.setMods(server, [])).rejects.toThrow(ApiError);
});
});
+77
View File
@@ -0,0 +1,77 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { MockGameServerProvider } from '../src/modules/pterodactyl/mock-provider.js';
import { ConfigFileGateway } from '../src/modules/config/config-file-gateway.js';
import { PerformanceSettingsService } from '../src/modules/config/performance-service.js';
import type { ConfigSyncService } from '../src/modules/config/config-sync.js';
import type { ServerRecord } from '../src/modules/servers/server-service.js';
import { createLogger } from '../src/lib/logger.js';
const server: ServerRecord = {
id: 'srv-1',
slug: 'training-server',
name: 'SCAR Operations',
providerType: 'pterodactyl',
pterodactylServerId: 'abc123',
status: 'online',
maxPlayers: 16,
createdAt: new Date(),
updatedAt: new Date(),
};
describe('PerformanceSettingsService', () => {
let provider: MockGameServerProvider;
let service: PerformanceSettingsService;
beforeEach(() => {
provider = new MockGameServerProvider();
const configSync = { sync: async () => ({}) } as unknown as ConfigSyncService;
service = new PerformanceSettingsService(
new ConfigFileGateway(provider, '/config.json'),
configSync,
createLogger('silent'),
);
});
it('reads current values, reporting absent keys as null', async () => {
const { settings } = await service.get(server);
// Present in the mock config.json:
expect(settings.maxPlayers).toBe(16);
expect(settings.serverMaxViewDistance).toBe(2500);
expect(settings.aiLimit).toBe(40);
expect(settings.disableThirdPerson).toBe(false);
// Absent keys:
expect(settings.playerSaveTime).toBeNull();
expect(settings.fastValidation).toBeNull();
});
it('sets changed values and removes nulled keys, preserving everything else', async () => {
const { settings } = await service.get(server);
const result = await service.update(server, {
...settings,
maxPlayers: 32,
playerSaveTime: 180, // new key
aiLimit: null, // remove key → game default
});
expect(result.changedFields.sort()).toEqual(['aiLimit', 'maxPlayers', 'playerSaveTime']);
expect(result.requiresRestart).toBe(true);
const written = JSON.parse(provider.writtenFiles.get('/config.json')!);
expect(written.game.maxPlayers).toBe(32);
expect(written.operating.playerSaveTime).toBe(180);
expect('aiLimit' in written.operating).toBe(false);
// Untouched fields preserved:
expect(written.bindPort).toBe(2001);
expect(written.game.scenarioId).toContain('Missions');
expect(written.game.mods).toHaveLength(1);
// Backup written:
expect(provider.writtenFiles.get('/config.json.bak')).toBeTruthy();
});
it('does not write the file at all when nothing changed', async () => {
const { settings } = await service.get(server);
const result = await service.update(server, settings);
expect(result.changedFields).toEqual([]);
expect(provider.writtenFiles.has('/config.json')).toBe(false);
});
});
+137
View File
@@ -0,0 +1,137 @@
import { describe, expect, it, vi } from 'vitest';
import { MockGameServerProvider } from '../src/modules/pterodactyl/mock-provider.js';
import { PterodactylProvider } from '../src/modules/pterodactyl/pterodactyl-provider.js';
import { ApiError } from '../src/lib/errors.js';
const API_KEY = 'ptlc_super_secret_key_123';
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
describe('MockGameServerProvider', () => {
it('reports online with plausible resources by default', async () => {
const provider = new MockGameServerProvider();
expect(await provider.getServerStatus()).toBe('online');
const resources = await provider.getServerResources();
expect(resources.status).toBe('online');
expect(resources.memoryLimitBytes).toBeGreaterThan(0);
expect(resources.uptimeMs).toBeGreaterThan(0);
provider.dispose();
});
it('transitions through stopping on stop', async () => {
const provider = new MockGameServerProvider();
await provider.stopServer();
expect(await provider.getServerStatus()).toBe('stopping');
provider.dispose();
});
it('serves a parseable console.log fixture', async () => {
const provider = new MockGameServerProvider();
const file = await provider.downloadTextFile('any', '/profile/logs/console.log');
expect(file.content).toContain('connected');
expect(file.content).toContain('Log started');
expect(file.contentStartOffset).toBe(0);
provider.dispose();
});
it('rejects unknown paths instead of exposing a file system', async () => {
const provider = new MockGameServerProvider();
await expect(provider.downloadTextFile('any', '/etc/passwd')).rejects.toThrow(ApiError);
provider.dispose();
});
});
describe('PterodactylProvider', () => {
it('maps resource responses from the Client API', async () => {
const fetchImpl = vi.fn(async (url: string | URL, _init?: RequestInit) => {
const path = String(url);
if (path.endsWith('/resources')) {
return jsonResponse({
object: 'stats',
attributes: {
current_state: 'running',
resources: {
memory_bytes: 1024,
cpu_absolute: 51.5,
disk_bytes: 2048,
network_rx_bytes: 10,
network_tx_bytes: 20,
uptime: 5000,
},
},
});
}
return jsonResponse({ attributes: { limits: { cpu: 400, memory: 8192, disk: 40960 } } });
});
const provider = new PterodactylProvider({
baseUrl: 'https://panel.example.com',
apiKey: API_KEY,
fetchImpl: fetchImpl as unknown as typeof fetch,
});
const resources = await provider.getServerResources('abc123');
expect(resources.status).toBe('online');
expect(resources.cpuPercent).toBe(51.5);
expect(resources.cpuLimitPercent).toBe(400);
expect(resources.memoryLimitBytes).toBe(8192 * 1024 * 1024);
const [calledUrl, calledInit] = fetchImpl.mock.calls[0]!;
expect(String(calledUrl)).toBe('https://panel.example.com/api/client/servers/abc123/resources');
const headers = calledInit?.headers as Record<string, string>;
expect(headers.Authorization).toBe(`Bearer ${API_KEY}`);
});
it('sends power signals with the expected body', async () => {
const fetchImpl = vi.fn(async (_url: string | URL, _init?: RequestInit) => {
return new Response(null, { status: 204 });
});
const provider = new PterodactylProvider({
baseUrl: 'https://panel.example.com',
apiKey: API_KEY,
fetchImpl: fetchImpl as unknown as typeof fetch,
});
await provider.restartServer('abc123');
const [url, init] = fetchImpl.mock.calls[0]!;
expect(String(url)).toContain('/power');
expect(init?.method).toBe('POST');
expect(JSON.parse(String(init?.body))).toEqual({ signal: 'restart' });
});
it('maps HTTP errors without leaking the API key or full URL', async () => {
const fetchImpl = vi.fn(async () => new Response('nope', { status: 500 }));
const provider = new PterodactylProvider({
baseUrl: 'https://panel.example.com',
apiKey: API_KEY,
fetchImpl: fetchImpl as unknown as typeof fetch,
});
const error = await provider.getServerResources('abc123').catch((e: unknown) => e as ApiError);
expect(error).toBeInstanceOf(ApiError);
expect((error as ApiError).code).toBe('UPSTREAM_UNAVAILABLE');
expect((error as ApiError).message).not.toContain(API_KEY);
expect((error as ApiError).message).not.toContain('panel.example.com');
expect((error as ApiError).message).toContain('500');
});
it('maps timeouts to a sanitized upstream error', async () => {
const timeoutError = new Error('The operation was aborted due to timeout');
timeoutError.name = 'TimeoutError';
const fetchImpl = vi.fn(async () => {
throw timeoutError;
});
const provider = new PterodactylProvider({
baseUrl: 'https://panel.example.com',
apiKey: API_KEY,
fetchImpl: fetchImpl as unknown as typeof fetch,
timeoutMs: 50,
});
const error = await provider.getServerStatus('abc123').catch((e: unknown) => e as ApiError);
expect(error).toBeInstanceOf(ApiError);
expect((error as ApiError).message).toContain('timed out');
expect((error as ApiError).message).not.toContain(API_KEY);
});
});