Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 214 additions & 0 deletions apps/core/src/routes/api/setup.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
/**
* biome-ignore-all lint/suspicious/noExplicitAny: fakes for repo/stat are cast
* to satisfy handler options & mocked module shapes
*/
import {
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
mock,
} from 'bun:test';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import path from 'node:path';

const mockSettingsSet = mock(() => {});
const mockGroupsList = mock(() => []);
const mockGroupsDelete = mock(() => {});
const mockGroupsCreate = mock(() => {});
const mockCountUsers = mock(() => 0);
const mockCreateUser = mock(async (username: string) => ({
id: 1,
username,
}));
const mockCreateSession = mock((_userId: number) => ({ id: 'test-session' }));

mock.module('@fxmanager/database', () => ({
repo: {
settings: { set: mockSettingsSet },
groups: {
list: mockGroupsList,
delete: mockGroupsDelete,
create: mockGroupsCreate,
},
auth: {
countUsers: mockCountUsers,
createUser: mockCreateUser,
createSession: mockCreateSession,
},
},
}));

const fileSystem = new Map<string, 'file' | 'dir'>();
const mockStat = mock(async (targetPath: string) => {
const normalizedPath = path.normalize(targetPath);
const type = fileSystem.get(normalizedPath);

if (!type)
throw new Error(
`ENOENT: no such file or directory, stat '${normalizedPath}'`,
);

return {
isDirectory: () => type === 'dir',
isFile: () => type === 'file',
};
});

mock.module('node:fs/promises', () => ({
stat: mockStat,
access: async () => {},
}));

import { ConfigManager } from '../../modules/config/manager';
import { setupTokenManager } from '../../modules/setup/token';
import SetupModule from './setup';

describe('setup endpoint (HTTP)', () => {
let app: FastifyInstance;
let token: string;
let originalPlatform: NodeJS.Platform;

const setPlatform = (platform: NodeJS.Platform) => {
Object.defineProperty(process, 'platform', { value: platform });
};

const postSetup = (fxserverPath: string, resourcePath: string) =>
app.inject({
method: 'POST',
url: '/setup',
headers: {
'content-type': 'application/json',
'x-setup-token': token,
},
payload: {
username: 'admin',
password: 'password123',
server: { method: 'manual', fxserverPath, resourcePath },
customGroups: [],
},
});

beforeAll(async () => {
app = Fastify();
await app.register(fastifyCookie);
await app.register(SetupModule.handler, {
prefix: SetupModule.prefix,
} as any);
await app.ready();
});

beforeEach(() => {
originalPlatform = process.platform;
fileSystem.clear();
mockStat.mockClear();
(ConfigManager as any).instance = null;
token = setupTokenManager.ensure(); // the handler clears it on success

for (const m of [
mockSettingsSet,
mockGroupsList,
mockGroupsDelete,
mockGroupsCreate,
mockCountUsers,
mockCreateUser,
mockCreateSession,
])
m.mockClear();
});

afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform });
});

it('resolves an fxServer directory down to the child FXServer.exe before persisting (windows)', async () => {
setPlatform('win32');
const installRoot = path.resolve('fake-install-root');
const fxServerDir = path.join(installRoot, 'fxServer');
const exePath = path.join(fxServerDir, 'FXServer.exe');
const dataDir = path.join(installRoot, 'server-data');

fileSystem.set(installRoot, 'dir');
fileSystem.set(fxServerDir, 'dir');
fileSystem.set(exePath, 'file');
fileSystem.set(dataDir, 'dir');

const res = await postSetup(fxServerDir, dataDir);

expect(res.statusCode).toBe(201);
expect(mockSettingsSet).toHaveBeenCalledWith(
'fxserver.executablePath',
exePath,
);
expect(mockSettingsSet).toHaveBeenCalledWith(
'fxserver.serverDataPath',
dataDir,
);
// the raw directory must never be what gets stored
expect(mockSettingsSet).not.toHaveBeenCalledWith(
'fxserver.executablePath',
fxServerDir,
);
});

it('persists a direct executable file path unchanged', async () => {
setPlatform('win32');
const exePath = path.resolve('bin', 'FXServer.exe');
const dataDir = path.resolve('server-data');

fileSystem.set(path.dirname(exePath), 'dir');
fileSystem.set(exePath, 'file');
fileSystem.set(dataDir, 'dir');

const res = await postSetup(exePath, dataDir);

expect(res.statusCode).toBe(201);
expect(mockSettingsSet).toHaveBeenCalledWith(
'fxserver.executablePath',
exePath,
);
});

it('resolves nested Linux alpine fxserver directories', async () => {
setPlatform('linux');
const installRoot = path.resolve('fake-install-root');
const fxServerDir = path.join(installRoot, 'fxserver');
const alpineDir = path.join(fxServerDir, 'alpine', 'opt', 'cfx-server');
const exePath = path.join(alpineDir, 'fxserver');
const dataDir = path.join(installRoot, 'server-data');

fileSystem.set(installRoot, 'dir');
fileSystem.set(fxServerDir, 'dir');
fileSystem.set(alpineDir, 'dir');
fileSystem.set(exePath, 'file');
fileSystem.set(dataDir, 'dir');

const res = await postSetup(fxServerDir, dataDir);

expect(res.statusCode).toBe(201);
expect(mockSettingsSet).toHaveBeenCalledWith(
'fxserver.executablePath',
exePath,
);
});

it('keeps an unresolvable path instead of rejecting (proceed-anyway support)', async () => {
setPlatform('win32');
const fxServerDir = path.resolve('empty-install');
const dataDir = path.resolve('server-data');

fileSystem.set(fxServerDir, 'dir');
fileSystem.set(dataDir, 'dir');

const res = await postSetup(fxServerDir, dataDir);

expect(res.statusCode).toBe(201);
expect(mockSettingsSet).toHaveBeenCalledWith(
'fxserver.executablePath',
fxServerDir,
);
});
});
14 changes: 12 additions & 2 deletions apps/core/src/routes/api/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,18 @@ const SetupEndpoint: FastifyPluginAsync = async (fastify) => {

const { username, password, server, customGroups } = request.body;

repo.settings.set('fxserver.executablePath', server.fxserverPath);
repo.settings.set('fxserver.serverDataPath', server.resourcePath);
// Normalise the supplied paths before persisting, mirroring what the
// /detect + /checkfiles endpoints return
const execResult =
await ConfigManager.getInstance().validateExecutablePath(
server.fxserverPath,
);
const dataResult = await ConfigManager.getInstance().validateDataPath(
server.resourcePath,
);

repo.settings.set('fxserver.executablePath', execResult.path);
repo.settings.set('fxserver.serverDataPath', dataResult.path);

if (customGroups.length > 0) {
try {
Expand Down
27 changes: 20 additions & 7 deletions apps/webpanel/src/pages/setup/ServerStep.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import {
InfoIcon,
ArrowRight,
Expand All @@ -23,6 +23,9 @@ import {
import type { SetupFormData } from './types';
import { getSetupToken } from './setup-token';

// Auto-detection runs once per page load
let autoDetected = false;

interface ServerStepProps {
formData: SetupFormData;
onChange: <K extends keyof SetupFormData>(
Expand Down Expand Up @@ -183,9 +186,19 @@ export function ServerStep({ formData, onChange, onNext }: ServerStepProps) {

function selectAutomatic() {
onChange('serverSetupMethod', 'installer');
if (autoDetected) return;
autoDetected = true;
if (!detecting) runDetect();
}

// biome-ignore lint/correctness/useExhaustiveDependencies: mount-only auto-detect; re-runs happen via the re-scan button.
useEffect(() => {
if (formData.serverSetupMethod !== 'installer') return;
if (autoDetected) return;
autoDetected = true;
void runDetect();
}, []);

return (
<>
<div className="flex flex-col gap-6 py-4 w-full relative">
Expand All @@ -203,22 +216,22 @@ export function ServerStep({ formData, onChange, onNext }: ServerStepProps) {
<Button
type="button"
variant={
formData.serverSetupMethod === 'manual' ? 'default' : 'ghost'
formData.serverSetupMethod === 'installer' ? 'default' : 'ghost'
}
size="sm"
onClick={selectManual}
onClick={selectAutomatic}
>
Local Directory Binding
Auto-detect (Docker/Installer)
</Button>
<Button
type="button"
variant={
formData.serverSetupMethod === 'installer' ? 'default' : 'ghost'
formData.serverSetupMethod === 'manual' ? 'default' : 'ghost'
}
size="sm"
onClick={selectAutomatic}
onClick={selectManual}
>
Auto-detect (Docker)
Local Directory Binding
</Button>
</div>

Expand Down
2 changes: 1 addition & 1 deletion apps/webpanel/src/pages/setup/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export function SetupApp() {
username: '',
password: '',
confirmPassword: '',
serverSetupMethod: 'manual',
serverSetupMethod: 'installer',
fxserverPath: '',
resourcePath: '',
adminGroups: [],
Expand Down
Loading