427 lines
14 KiB
TypeScript
427 lines
14 KiB
TypeScript
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);
|
|
});
|
|
});
|