initial commit
This commit is contained in:
106 files changed
+24584
No files matched your search
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user