fix(games): require games.play to launch a game

GET /api/games/:id/launch only checked games.read via
validateSharedGameAccess, so an authenticated user whose role lacked
games.play could still mount and download games through the API.
This commit is contained in:
Darkraise
2026-09-28 12:09:09 +07:00
parent 2724a40325
commit 2e9e9490b5
2 changed files with 104 additions and 0 deletions
+102
View File
@@ -0,0 +1,102 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import express, { Express } from 'express';
import request from 'supertest';
vi.mock('../utils/logger', () => ({
logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() },
}));
const passThrough = vi.hoisted(() => (_req: unknown, _res: unknown, next: () => void) => next());
vi.mock('../middleware/rateLimiter', () => ({
rateLimitStandard: passThrough,
}));
vi.mock('../middleware/activityLogger', () => ({
logActivity: () => passThrough,
}));
// Auth and RBAC run for real; only token verification and settings are stubbed.
vi.mock('../services/AuthService');
const getGameById = vi.hoisted(() => vi.fn());
vi.mock('../services/GameService', () => ({
GameService: vi.fn().mockImplementation(() => ({
getGameById,
getGameDataPath: vi.fn().mockResolvedValue(null),
})),
}));
vi.mock('../services/GameSearchCache', () => ({
GameSearchCache: {},
}));
const mountGameZip = vi.hoisted(() => vi.fn());
vi.mock('../services/GameDataService', () => ({
gameDataService: { mountGameZip },
}));
import gamesRouter from './games';
import { errorHandler } from '../middleware/errorHandler';
import { AuthService } from '../services/AuthService';
const GAME_ID = '11111111-2222-4333-8444-555555555555';
function createApp(): Express {
const app = express();
app.use('/api/games', gamesRouter);
app.use(errorHandler);
return app;
}
function signInAs(permissions: string[]): void {
vi.mocked(AuthService.prototype.verifyAccessToken).mockResolvedValue({
id: 42,
username: 'player',
email: 'player@example.com',
role: 'user',
permissions,
});
}
describe('GET /api/games/:id/launch', () => {
beforeEach(() => {
vi.clearAllMocks();
getGameById.mockResolvedValue({
id: GAME_ID,
title: 'Test Game',
platformName: 'Flash',
launchCommand: 'http://example.com/game.swf',
source: 'http://example.com',
});
mountGameZip.mockResolvedValue({ mounted: true, downloading: false });
});
it('rejects users without games.play before mounting anything', async () => {
signInAs(['games.read']);
const response = await request(createApp())
.get(`/api/games/${GAME_ID}/launch`)
.set('Authorization', 'Bearer token');
expect(response.status).toBe(403);
expect(mountGameZip).not.toHaveBeenCalled();
});
it('launches for users with games.play', async () => {
signInAs(['games.read', 'games.play']);
const response = await request(createApp())
.get(`/api/games/${GAME_ID}/launch`)
.set('Authorization', 'Bearer token');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({ gameId: GAME_ID, canPlayInBrowser: true });
expect(mountGameZip).toHaveBeenCalledWith(GAME_ID, { allowRecovery: true });
});
it('launches for guests when guest access is enabled', async () => {
vi.mocked(AuthService.prototype.isGuestAccessEnabled).mockReturnValue(true);
const response = await request(createApp()).get(`/api/games/${GAME_ID}/launch`);
expect(response.status).toBe(200);
expect(mountGameZip).toHaveBeenCalledOnce();
});
});
+2
View File
@@ -6,6 +6,7 @@ import { AppError } from '../middleware/errorHandler';
import { asyncHandler } from '../middleware/asyncHandler';
import { sharedAccessAuth, validateSharedGameAccess } from '../middleware/auth';
import { logActivity } from '../middleware/activityLogger';
import { requirePermission } from '../middleware/rbac';
import { rateLimitStandard } from '../middleware/rateLimiter';
import { z } from 'zod';
@@ -252,6 +253,7 @@ router.get(
router.get(
'/:id/launch',
requirePermission('games.play'),
validateSharedGameAccess('id'),
logActivity('games.launch.request', 'games', (req, res) => ({
platform: res.locals.platform,