Initial commit: CraftDock as of 2026-11-01

The previous GitHub history was lost with the account (the local .git was missing
its refs and most objects), so this repository starts from the existing code.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Sonnet 5.5 committed 2026-11-01 22:44:39 -03:00
commit 640e52c0c9
99 files changed
+12115

No files matched your search

+23
View File
@@ -0,0 +1,23 @@
import { describe, it, expect, beforeAll } from 'vitest';
import bcrypt from 'bcryptjs';
import { createAuthService } from '../../src/services/authService.js';
describe('authService', () => {
let svc;
beforeAll(() => {
const hash = bcrypt.hashSync('correct', 12);
svc = createAuthService({ adminPasswordHash: hash });
});
it('accepts the correct password', async () => {
expect(await svc.verifyPassword('correct')).toBe(true);
});
it('rejects a wrong password', async () => {
expect(await svc.verifyPassword('wrong')).toBe(false);
});
it('rejects empty input without throwing', async () => {
expect(await svc.verifyPassword('')).toBe(false);
});
});
@@ -0,0 +1,49 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import fsp from 'node:fs/promises';
import { createBedrockIdentityBridge } from '../../src/services/bedrockIdentityBridge.js';
vi.mock('node:fs/promises');
describe('bedrockIdentityBridge', () => {
let store;
beforeEach(() => {
store = null;
vi.mocked(fsp.writeFile).mockImplementation(async (_p, data) => { store = data; });
vi.mocked(fsp.readFile).mockImplementation(async () => {
if (store == null) throw new Error('ENOENT');
return store;
});
});
it('learn binds xuid to the LevelDB uuid when uniqueId matches', async () => {
const fileAdapter = { findByUniqueId: vi.fn(async (u) => u === '-8589934591' ? { uuid: 'abc-uuid', data: {} } : null) };
const bridge = createBedrockIdentityBridge({ dataRoot: '/data', fileAdapter });
const binding = await bridge.learn({ xuid: '2535407895138987', name: 'Lucasrguerra', uniqueId: '-8589934591' });
expect(binding.leveldbUuid).toBe('abc-uuid');
expect(await bridge.resolveLeveldbUuid('2535407895138987')).toBe('abc-uuid');
});
it('learn returns null and persists nothing when uniqueId matches no player', async () => {
const fileAdapter = { findByUniqueId: vi.fn(async () => null) };
const bridge = createBedrockIdentityBridge({ dataRoot: '/data', fileAdapter });
expect(await bridge.learn({ xuid: 'x', name: 'n', uniqueId: '-1' })).toBeNull();
expect(store).toBeNull();
});
it('learn binds xuid to fallbackUuid when provided even without uniqueId', async () => {
const fileAdapter = { findByUniqueId: vi.fn() };
const bridge = createBedrockIdentityBridge({ dataRoot: '/data', fileAdapter });
const binding = await bridge.learn({ xuid: '2535407895138987', name: 'Lucasrguerra', fallbackUuid: 'fallback-uuid-123' });
expect(binding.leveldbUuid).toBe('fallback-uuid-123');
expect(await bridge.resolveLeveldbUuid('2535407895138987')).toBe('fallback-uuid-123');
expect(fileAdapter.findByUniqueId).not.toHaveBeenCalled();
});
it('resolveLeveldbUuid returns null for an unknown xuid', async () => {
const bridge = createBedrockIdentityBridge({ dataRoot: '/data', fileAdapter: {} });
expect(await bridge.resolveLeveldbUuid('nope')).toBeNull();
});
});
+61
View File
@@ -0,0 +1,61 @@
import { describe, it, expect, vi } from 'vitest';
import { createDockerService, ContainerNotFoundError } from '../../src/services/dockerService.js';
function mockDocker({ inspectData, exists = true } = {}) {
const container = {
start: vi.fn().mockResolvedValue(),
stop: vi.fn().mockResolvedValue(),
restart: vi.fn().mockResolvedValue(),
kill: vi.fn().mockResolvedValue(),
inspect: vi.fn().mockResolvedValue(inspectData),
};
return {
_container: container,
listContainers: vi.fn().mockResolvedValue(
exists ? [{ Names: ['/craftdock-mc-server'], Id: 'abc' }] : []
),
getContainer: vi.fn().mockReturnValue(container),
};
}
const config = { mcContainerName: 'craftdock-mc-server' };
describe('dockerService', () => {
it('inspect returns state and parsed TYPE', async () => {
const docker = mockDocker({
inspectData: {
State: { Status: 'running' },
Config: { Env: ['EULA=TRUE', 'TYPE=PAPER'] },
},
});
const svc = createDockerService(config, docker);
const info = await svc.inspect();
expect(info).toMatchObject({ found: true, state: 'running', type: 'PAPER' });
});
it('inspect reports not_found when container is absent', async () => {
const svc = createDockerService(config, mockDocker({ exists: false }));
const info = await svc.inspect();
expect(info).toEqual({ found: false, state: 'not_found', type: null });
});
it('start delegates to the resolved container', async () => {
const docker = mockDocker({ inspectData: { State: {}, Config: { Env: [] } } });
const svc = createDockerService(config, docker);
await svc.start();
expect(docker._container.start).toHaveBeenCalledOnce();
});
it('start throws ContainerNotFoundError when absent', async () => {
const svc = createDockerService(config, mockDocker({ exists: false }));
await expect(svc.start()).rejects.toBeInstanceOf(ContainerNotFoundError);
});
it('getState works when destructured (no `this` binding)', async () => {
const docker = mockDocker({
inspectData: { State: { Status: 'running' }, Config: { Env: [] } },
});
const { getState } = createDockerService(config, docker);
await expect(getState()).resolves.toBe('running');
});
});
@@ -0,0 +1,52 @@
import { describe, it, expect, vi } from 'vitest';
import os from 'node:os';
import { createDockerService } from '../../src/services/dockerService.js';
const config = { mcContainerName: 'craftdock-mc-server', mcServiceName: 'minecraft-server' };
// Simulates Coolify: the MC container has an auto-generated name (not
// craftdock-mc-server) but carries the compose service/project labels. The panel
// container (os.hostname()) shares the same project.
function mockCoolifyDocker() {
const selfId = os.hostname();
const mcData = { State: { Status: 'running' }, Config: { Env: ['TYPE=BEDROCK'] } };
const byId = {
[selfId]: { inspect: vi.fn().mockResolvedValue({ Config: { Labels: { 'com.docker.compose.project': 'proj' } } }) },
mcid: { inspect: vi.fn().mockResolvedValue(mcData), start: vi.fn().mockResolvedValue() },
};
return {
byId,
listContainers: vi.fn().mockResolvedValue([
{
Names: ['/minecraft-server-proj-abc123'],
Id: 'mcid',
Labels: { 'com.docker.compose.service': 'minecraft-server', 'com.docker.compose.project': 'proj' },
},
]),
getContainer: vi.fn((id) => byId[id] || { inspect: vi.fn().mockRejectedValue(new Error('no such container')) }),
};
}
describe('dockerService container resolution', () => {
it('resolves the MC container by compose service label when the name does not match', async () => {
const docker = mockCoolifyDocker();
const svc = createDockerService(config, docker);
const info = await svc.inspect();
expect(info).toMatchObject({ found: true, state: 'running', type: 'BEDROCK' });
});
it('scopes the label match to the panel own compose project', async () => {
const docker = mockCoolifyDocker();
// Same service label but a DIFFERENT project — must NOT match.
docker.listContainers.mockResolvedValue([
{
Names: ['/minecraft-server-other-xyz'],
Id: 'mcid',
Labels: { 'com.docker.compose.service': 'minecraft-server', 'com.docker.compose.project': 'other-project' },
},
]);
const svc = createDockerService(config, docker);
const info = await svc.inspect();
expect(info).toEqual({ found: false, state: 'not_found', type: null });
});
});
+57
View File
@@ -0,0 +1,57 @@
import { describe, it, expect, vi } from 'vitest';
import { createDockerService } from '../../src/services/dockerService.js';
const statsSample = {
cpu_stats: {
cpu_usage: { total_usage: 2000000000 },
system_cpu_usage: 20000000000,
online_cpus: 2,
},
precpu_stats: {
cpu_usage: { total_usage: 1000000000 },
system_cpu_usage: 10000000000,
},
memory_stats: { usage: 536870912, limit: 1073741824 }, // 512MB / 1GB
};
function mockDocker() {
const container = {
inspect: vi.fn().mockResolvedValue({ State: { Status: 'running' }, Config: { Env: [] } }),
stats: vi.fn().mockResolvedValue(statsSample),
};
return {
listContainers: vi.fn().mockResolvedValue([{ Names: ['/mc'], Id: 'x' }]),
getContainer: vi.fn().mockReturnValue(container),
};
}
describe('dockerService.stats', () => {
it('computes cpu and memory percentages', async () => {
const svc = createDockerService({ mcContainerName: 'mc' }, mockDocker());
const s = await svc.stats();
// cpuDelta=1e9, sysDelta=1e10 -> 0.1 * 2 cpus * 100 = 20%
expect(s.cpuPct).toBeCloseTo(20, 1);
expect(s.memUsedMb).toBeCloseTo(512, 0);
expect(s.memPct).toBeCloseTo(50, 1);
});
it('returns zeros for a malformed/empty stats payload (e.g. stopped container)', async () => {
const docker = mockDocker();
docker.getContainer().stats.mockResolvedValue({
cpu_stats: {},
precpu_stats: {},
memory_stats: {},
});
const svc = createDockerService({ mcContainerName: 'mc' }, docker);
const s = await svc.stats();
expect(s).toEqual({ cpuPct: 0, memUsedMb: 0, memPct: 0 });
});
it('returns zeros for a completely empty stats payload', async () => {
const docker = mockDocker();
docker.getContainer().stats.mockResolvedValue({});
const svc = createDockerService({ mcContainerName: 'mc' }, docker);
const s = await svc.stats();
expect(s).toEqual({ cpuPct: 0, memUsedMb: 0, memPct: 0 });
});
});
@@ -0,0 +1,38 @@
// test/services/dockerServiceStreams.test.js
import { describe, it, expect, vi } from 'vitest';
import { createDockerService } from '../../src/services/dockerService.js';
function mockDocker() {
const container = {
inspect: vi.fn().mockResolvedValue({ State: {}, Config: { Env: [] } }),
logs: vi.fn().mockResolvedValue('LOGSTREAM'),
attach: vi.fn().mockResolvedValue('ATTACHSTREAM'),
};
return {
_container: container,
listContainers: vi.fn().mockResolvedValue([{ Names: ['/mc'], Id: 'x' }]),
getContainer: vi.fn().mockReturnValue(container),
};
}
describe('dockerService streams', () => {
it('logStream requests a following log stream with tail', async () => {
const docker = mockDocker();
const svc = createDockerService({ mcContainerName: 'mc' }, docker);
const stream = await svc.logStream();
expect(stream).toBe('LOGSTREAM');
expect(docker._container.logs).toHaveBeenCalledWith(
expect.objectContaining({ follow: true, stdout: true, stderr: true })
);
});
it('attach requests a duplex stream with stdin', async () => {
const docker = mockDocker();
const svc = createDockerService({ mcContainerName: 'mc' }, docker);
const stream = await svc.attach();
expect(stream).toBe('ATTACHSTREAM');
expect(docker._container.attach).toHaveBeenCalledWith(
expect.objectContaining({ stream: true, stdin: true })
);
});
});
+49
View File
@@ -0,0 +1,49 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import fsp from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { ensureFirstBootDefaults, MARKER_FILE } from '../../src/services/firstBootService.js';
let tmp, propertiesService, logger;
beforeEach(async () => {
tmp = await fsp.mkdtemp(path.join(os.tmpdir(), 'cd-fb-'));
propertiesService = { update: vi.fn().mockResolvedValue({}) };
logger = { info: vi.fn(), warn: vi.fn() };
});
afterEach(async () => { await fsp.rm(tmp, { recursive: true, force: true }); });
const run = (edition = 'bedrock') =>
ensureFirstBootDefaults({ config: { mcDataPath: tmp, mcEdition: edition }, propertiesService, logger });
describe('ensureFirstBootDefaults', () => {
it('first boot: forces the whitelist property to false and writes the marker', async () => {
await run('bedrock');
expect(propertiesService.update).toHaveBeenCalledWith({ 'allow-list': 'false' });
const marker = JSON.parse(await fsp.readFile(path.join(tmp, MARKER_FILE), 'utf8'));
expect(marker.initializedAt).toBeTruthy();
});
it('java edition writes white-list', async () => {
await run('java');
expect(propertiesService.update).toHaveBeenCalledWith({ 'white-list': 'false' });
});
it('auto edition writes both keys (each server only reads its own)', async () => {
await run('auto');
expect(propertiesService.update).toHaveBeenCalledWith({ 'allow-list': 'false', 'white-list': 'false' });
});
it('restart (marker present): does not touch properties', async () => {
await fsp.writeFile(path.join(tmp, MARKER_FILE), JSON.stringify({ initializedAt: 'x' }));
await run('bedrock');
expect(propertiesService.update).not.toHaveBeenCalled();
});
it('never throws: a failing properties write is logged, and no marker is left so it retries next boot', async () => {
propertiesService.update.mockRejectedValue(new Error('disk full'));
await expect(run('bedrock')).resolves.toBeUndefined();
expect(logger.warn).toHaveBeenCalled();
await expect(fsp.access(path.join(tmp, MARKER_FILE))).rejects.toThrow();
});
});
+97
View File
@@ -0,0 +1,97 @@
import { describe, it, expect, vi } from 'vitest';
import { createPlayerDetailService } from '../../src/services/playerDetailService.js';
function setup({ edition, online, running = true, dir, bridge, readPlayer, listServerUuids, extraAdapter = {} }) {
const adapter = {
_edition: edition,
listPlayers: vi.fn(async () => ({ players: online ? ['Lucas'] : [] })),
queryUniqueId: vi.fn(async () => '-8589934591'),
saveHold: vi.fn(async () => true),
saveResume: vi.fn(async () => {}),
forceSave: vi.fn(async () => {}),
...extraAdapter,
};
const deps = {
config: { mcDataPath: '/data' },
appState: { getAdapter: vi.fn(async () => adapter) },
dockerService: { inspect: vi.fn(async () => ({ state: running ? 'running' : 'exited' })) },
readDirectory: vi.fn(async () => dir),
createFileAdapter: vi.fn(() => ({
readPlayer: vi.fn(readPlayer),
findByUniqueId: vi.fn(),
listServerUuids: vi.fn(async () => listServerUuids ? listServerUuids() : []),
})),
createBridge: vi.fn(() => bridge),
};
return { adapter, service: createPlayerDetailService(deps), deps };
}
describe('playerDetailService', () => {
it('returns null for an unknown xuid', async () => {
const { service } = setup({ edition: 'bedrock', online: false, dir: {}, bridge: {} });
expect(await service.getDetail('nope')).toBeNull();
});
it('bedrock online: learns the bridge and reads under a save snapshot', async () => {
const bridge = {
learn: vi.fn(async () => ({})),
resolveLeveldbUuid: vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce('uuid-1'),
};
const { adapter, service } = setup({
edition: 'bedrock', online: true,
dir: { '2535': { name: 'Lucas', xuid: '2535' } },
bridge,
readPlayer: async (id) => id === 'uuid-1' ? { position: { x: 1, y: 2, z: 3 }, health: { current: 9, max: 20 } } : null,
});
const d = await service.getDetail('2535');
expect(d.online).toBe(true);
expect(d.position).toEqual({ x: 1, y: 2, z: 3 });
expect(bridge.learn).toHaveBeenCalledWith({ xuid: '2535', name: 'Lucas', uniqueId: '-8589934591' });
expect(adapter.saveHold).toHaveBeenCalled();
expect(adapter.saveResume).toHaveBeenCalled();
});
it('bedrock offline and never bridged: auto-binds single LevelDB uuid fallback', async () => {
const bridge = { learn: vi.fn(async () => ({})), resolveLeveldbUuid: vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce('uuid-fallback-1') };
const { service } = setup({
edition: 'bedrock', online: false, running: false,
dir: { '2535': { name: 'Lucas', xuid: '2535' } },
bridge,
readPlayer: async (id) => id === 'uuid-fallback-1' ? { health: 20 } : null,
listServerUuids: () => ['uuid-fallback-1'],
});
const d = await service.getDetail('2535');
expect(d.needsBridge).toBe(false);
expect(d.health).toBe(20);
expect(bridge.learn).toHaveBeenCalledWith({ xuid: '2535', name: 'Lucas', fallbackUuid: 'uuid-fallback-1' });
});
it('bedrock offline and never bridged with multiple uuids: returns needsBridge', async () => {
const bridge = { learn: vi.fn(), resolveLeveldbUuid: vi.fn(async () => null) };
const { service } = setup({
edition: 'bedrock', online: false,
dir: { '2535': { name: 'Lucas', xuid: '2535' } },
bridge,
readPlayer: async () => null,
listServerUuids: () => ['uuid-1', 'uuid-2'],
});
const d = await service.getDetail('2535');
expect(d.needsBridge).toBe(true);
});
it('java online: forces save then reads the player .dat', async () => {
const { adapter, service } = setup({
edition: 'java', online: true,
dir: { 'uuid-xyz': { name: 'Lucas', xuid: 'uuid-xyz' } },
bridge: {},
readPlayer: async (id) => id === 'uuid-xyz' ? { position: { x: 0, y: 64, z: 0 }, food: 20 } : null,
});
const d = await service.getDetail('uuid-xyz');
expect(adapter.forceSave).toHaveBeenCalled();
expect(d.food).toBe(20);
expect(d.needsBridge).toBe(false);
});
});
+108
View File
@@ -0,0 +1,108 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import fsp from 'node:fs/promises';
import { updatePlayerDirectory, queryDirectory } from '../../src/services/playerDirectory.js';
vi.mock('node:fs/promises');
// A dockerService whose container returns the given log text, recording the opts
// each `logs()` call was made with (to assert incremental scanning).
function mockDocker(logText) {
const calls = [];
return {
calls,
getContainer: async () => ({
logs: async (opts) => { calls.push(opts); return Buffer.from(logText); },
}),
};
}
describe('playerDirectory', () => {
let store;
beforeEach(() => {
store = null;
vi.mocked(fsp.writeFile).mockImplementation(async (_p, data) => { store = data; });
vi.mocked(fsp.rm).mockResolvedValue();
// No existing directory / no legacy file by default.
vi.mocked(fsp.readFile).mockRejectedValue(new Error('ENOENT'));
});
it('records only players that actually spawned, keyed by XUID', async () => {
const log = [
'Player connected: Rejected, xuid: 111', // connect only → rejected by allowlist
'Player Spawned: Lucasrguerra xuid: 222', // real access
].join('\n');
const list = await updatePlayerDirectory({ dataRoot: '/data', dockerService: mockDocker(log), edition: 'bedrock' });
expect(list).toEqual([
expect.objectContaining({ xuid: '222', name: 'Lucasrguerra' }),
]);
// Rejected player (connected but never spawned) is absent.
expect(list.find((e) => e.name === 'Rejected')).toBeUndefined();
expect(JSON.parse(store)).toHaveProperty('222');
});
it('parses gamertags containing spaces', async () => {
const list = await updatePlayerDirectory({
dataRoot: '/data', dockerService: mockDocker('Player Spawned: Lucas Guerra xuid: 333'), edition: 'bedrock',
});
expect(list[0]).toMatchObject({ xuid: '333', name: 'Lucas Guerra' });
});
it('scans logs incrementally: bounded tail first, then since', async () => {
// Fresh module so the module-level scan cursor starts at 0 for this test.
vi.resetModules();
const { updatePlayerDirectory: fresh } = await import('../../src/services/playerDirectory.js');
const docker = mockDocker('Player Spawned: steve xuid: 1');
await fresh({ dataRoot: '/data', dockerService: docker, edition: 'bedrock' });
expect(docker.calls[0].tail).toBe(2000);
expect(docker.calls[0].since).toBeUndefined();
await fresh({ dataRoot: '/data', dockerService: docker, edition: 'bedrock' });
expect(docker.calls[1].since).toBeGreaterThan(0);
expect(docker.calls[1].tail).toBeUndefined();
});
it('keeps previously-seen players even when no longer spawning (Scenario 2)', async () => {
// Existing directory already has an old player; a new empty scan must not drop them.
vi.mocked(fsp.readFile).mockResolvedValue(JSON.stringify({
'999': { name: 'OldPlayer', xuid: '999', firstSeen: 'x', lastSeen: 'x' },
}));
const list = await updatePlayerDirectory({ dataRoot: '/data', dockerService: mockDocker(''), edition: 'bedrock' });
expect(list).toEqual([expect.objectContaining({ xuid: '999', name: 'OldPlayer' })]);
});
});
describe('queryDirectory', () => {
beforeEach(() => {
// 30 players named Player00..Player29, keyed by xuid.
const dir = {};
for (let i = 0; i < 30; i++) {
const n = String(i).padStart(2, '0');
dir[`x${n}`] = { xuid: `x${n}`, name: `Player${n}` };
}
vi.mocked(fsp.readFile).mockResolvedValue(JSON.stringify(dir));
});
it('returns the requested page with total', async () => {
const r = await queryDirectory('/data', { page: 2, pageSize: 25 });
expect(r.total).toBe(30);
expect(r.page).toBe(2);
expect(r.items).toHaveLength(5);
expect(r.items[0].name).toBe('Player25');
});
it('filters by name (case-insensitive) and paginates the filtered set', async () => {
const r = await queryDirectory('/data', { q: 'player0', pageSize: 100 });
expect(r.total).toBe(10); // Player00..Player09
expect(r.items.every((e) => e.name.startsWith('Player0'))).toBe(true);
});
it('clamps an out-of-range page to the last page', async () => {
const r = await queryDirectory('/data', { page: 99, pageSize: 25 });
expect(r.page).toBe(2);
expect(r.items).toHaveLength(5);
});
});
+48
View File
@@ -0,0 +1,48 @@
import { describe, it, expect, vi } from 'vitest';
import { createPropertiesService } from '../../src/services/propertiesService.js';
const SAMPLE = `#Minecraft server properties
#Mon Jul 18
difficulty=easy
pvp=true
max-players=20`;
function makeFs(content) {
const store = { value: content };
return {
store,
readFile: vi.fn().mockImplementation(async () => store.value),
writeFile: vi.fn().mockImplementation(async (_p, data) => { store.value = data; }),
};
}
describe('propertiesService', () => {
it('parses key=value ignoring comments', () => {
const svc = createPropertiesService({ mcDataPath: '/d' }, makeFs(SAMPLE));
const obj = svc.parse(SAMPLE);
expect(obj).toEqual({ difficulty: 'easy', pvp: 'true', 'max-players': '20' });
});
it('serialize preserves comments and updates values', () => {
const svc = createPropertiesService({ mcDataPath: '/d' }, makeFs(SAMPLE));
const out = svc.serialize({ difficulty: 'hard', pvp: 'true', 'max-players': '20' }, SAMPLE);
expect(out).toContain('#Minecraft server properties');
expect(out).toContain('difficulty=hard');
});
it('update merges patch and writes file', async () => {
const fs = makeFs(SAMPLE);
const svc = createPropertiesService({ mcDataPath: '/d' }, fs);
const merged = await svc.update({ difficulty: 'hard' });
expect(merged.difficulty).toBe('hard');
expect(fs.writeFile).toHaveBeenCalled();
expect(fs.store.value).toContain('difficulty=hard');
});
it('update appends new keys not present originally', async () => {
const fs = makeFs(SAMPLE);
const svc = createPropertiesService({ mcDataPath: '/d' }, fs);
await svc.update({ motd: 'Hello' });
expect(fs.store.value).toContain('motd=Hello');
});
});
+30
View File
@@ -0,0 +1,30 @@
import { describe, it, expect, vi } from 'vitest';
import { createRconService, RconUnavailableError } from '../../src/services/rconService.js';
function fakeRconClass({ failConnect = false } = {}) {
return class {
static async connect() {
if (failConnect) throw new Error('ECONNREFUSED');
return {
send: vi.fn().mockResolvedValue('command output'),
end: vi.fn().mockResolvedValue(),
on: vi.fn(),
};
}
};
}
const config = { rconHost: 'h', rconPort: 25575, rconPassword: 'p' };
describe('rconService', () => {
it('connects lazily and sends a command', async () => {
const svc = createRconService(config, fakeRconClass());
const out = await svc.send('list');
expect(out).toBe('command output');
});
it('throws RconUnavailableError when connect fails', async () => {
const svc = createRconService(config, fakeRconClass({ failConnect: true }));
await expect(svc.send('list')).rejects.toBeInstanceOf(RconUnavailableError);
});
});
+56
View File
@@ -0,0 +1,56 @@
import { describe, it, expect, vi } from 'vitest';
import { parseBedrockSeed, createSeedService } from '../../src/services/seedService.js';
function levelDatWithSeed(seed) {
const name = Buffer.from('RandomSeed', 'utf8');
const value = Buffer.alloc(8);
value.writeBigInt64LE(BigInt(seed));
return Buffer.concat([Buffer.from([0x04, 0x0a, 0x00]), name, value, Buffer.from('tail')]);
}
describe('parseBedrockSeed', () => {
it('reads the RandomSeed int64 (little-endian) from a level.dat buffer', () => {
expect(parseBedrockSeed(levelDatWithSeed('682981659723566343'))).toBe('682981659723566343');
});
it('handles negative seeds', () => {
expect(parseBedrockSeed(levelDatWithSeed('-4707462683490150816'))).toBe('-4707462683490150816');
});
it('returns null when RandomSeed is absent', () => {
expect(parseBedrockSeed(Buffer.from('no seed here'))).toBeNull();
});
});
describe('seedService.resolve', () => {
const config = { mcDataPath: '/data' };
it('prefers a non-empty level-seed from server.properties', async () => {
const propertiesService = { read: vi.fn().mockResolvedValue({ 'level-seed': '12345', 'level-name': 'Bedrock level' }) };
const svc = createSeedService({ config, propertiesService });
expect(await svc.resolve({}, 'bedrock')).toBe('12345');
});
it('reads bedrock seed from level.dat when level-seed is empty', async () => {
const propertiesService = { read: vi.fn().mockResolvedValue({ 'level-seed': '', 'level-name': 'Bedrock level' }) };
const fs = { readFile: vi.fn().mockResolvedValue(levelDatWithSeed('682981659723566343')) };
const svc = createSeedService({ config, propertiesService, fs });
expect(await svc.resolve({}, 'bedrock')).toBe('682981659723566343');
expect(fs.readFile).toHaveBeenCalledWith('/data/worlds/Bedrock level/level.dat');
});
it('uses the java adapter command when edition is java', async () => {
const propertiesService = { read: vi.fn().mockResolvedValue({ 'level-seed': '' }) };
const adapter = { getSeed: vi.fn().mockResolvedValue('99') };
const svc = createSeedService({ config, propertiesService });
expect(await svc.resolve(adapter, 'java')).toBe('99');
expect(adapter.getSeed).toHaveBeenCalled();
});
it('returns null (not throw) when bedrock level.dat is unreadable', async () => {
const propertiesService = { read: vi.fn().mockResolvedValue({ 'level-seed': '', 'level-name': 'Bedrock level' }) };
const fs = { readFile: vi.fn().mockRejectedValue(new Error('ENOENT')) };
const svc = createSeedService({ config, propertiesService, fs });
expect(await svc.resolve({}, 'bedrock')).toBeNull();
});
});
+113
View File
@@ -0,0 +1,113 @@
import { describe, it, expect, vi } from 'vitest';
import { PassThrough } from 'node:stream';
import { createStdinService } from '../../src/services/stdinService.js';
describe('stdinService', () => {
it('writes command with newline and captures output window', async () => {
const stream = new PassThrough();
const dockerService = { attach: vi.fn().mockResolvedValue(stream) };
const svc = createStdinService(dockerService, { windowMs: 20 });
const p = svc.send('list');
// simulate server echoing output
setTimeout(() => stream.write('There are 2 players online\n'), 5);
const out = await p;
expect(out).toContain('2 players online');
});
it('appends a newline to the written command', async () => {
const stream = new PassThrough();
const written = [];
stream.write = (chunk) => { written.push(chunk.toString()); return true; };
const dockerService = { attach: vi.fn().mockResolvedValue(stream) };
const svc = createStdinService(dockerService, { windowMs: 5 });
await svc.send('op steve');
expect(written[0]).toBe('op steve\n');
});
it('serializes concurrent sends (no interleaving)', async () => {
const stream = new PassThrough();
const order = [];
// Track write order to prove serialization
const originalWrite = stream.write.bind(stream);
stream.write = (...args) => {
order.push(args[0].toString().trim());
return originalWrite(...args);
};
const dockerService = { attach: vi.fn().mockResolvedValue(stream) };
const svc = createStdinService(dockerService, { windowMs: 40 });
// Fire 3 commands concurrently — they must NOT overlap
const p1 = svc.send('cmd1');
const p2 = svc.send('cmd2');
const p3 = svc.send('cmd3');
// Simulate server responses aimed at the MIDDLE of each serialized capture
// window ([0,40) / [40,80) / [80,120)ms) so timer jitter can't flake this.
setTimeout(() => stream.push('reply1\n'), 15);
setTimeout(() => stream.push('reply2\n'), 55);
setTimeout(() => stream.push('reply3\n'), 95);
const [r1, r2, r3] = await Promise.all([p1, p2, p3]);
// Commands must have been written in order (serialized)
expect(order).toEqual(['cmd1', 'cmd2', 'cmd3']);
// Each response captured in its own window
expect(r1).toContain('reply1');
expect(r2).toContain('reply2');
expect(r3).toContain('reply3');
});
it('re-attaches after the stream ends (e.g. container stop/start on world import)', async () => {
const stream1 = new PassThrough();
const stream2 = new PassThrough();
let n = 0;
const dockerService = {
attach: vi.fn().mockImplementation(async () => (++n === 1 ? stream1 : stream2)),
};
const svc = createStdinService(dockerService, { windowMs: 5 });
await svc.send('a');
expect(dockerService.attach).toHaveBeenCalledTimes(1);
// Container stopped: the attach stream ends. A stale, non-destroyed stream
// must NOT be reused — the next command has to re-attach.
stream1.emit('end');
await new Promise((r) => setTimeout(r, 1));
setTimeout(() => stream2.push('ok\n'), 2);
const out = await svc.send('b');
expect(dockerService.attach).toHaveBeenCalledTimes(2);
expect(out).toContain('ok');
});
it('queue continues even if a command rejects (stream error)', async () => {
let callCount = 0;
const dockerService = {
attach: vi.fn().mockImplementation(async () => {
callCount++;
if (callCount === 1) {
// First call: return a stream that immediately errors on write
const bad = new PassThrough();
bad.write = () => { throw new Error('broken pipe'); };
return bad;
}
// Second call: return a healthy stream
const good = new PassThrough();
setTimeout(() => good.push('ok\n'), 3);
return good;
}),
};
const svc = createStdinService(dockerService, { windowMs: 10 });
// First send should reject
await expect(svc.send('bad')).rejects.toThrow('broken pipe');
// The service must recover (re-attach) and the queue must not be stuck
const result = await svc.send('good');
expect(result).toContain('ok');
});
});
+70
View File
@@ -0,0 +1,70 @@
import { describe, it, expect } from 'vitest';
import fsp from 'node:fs/promises';
import { createReadStream, createWriteStream } from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import archiver from 'archiver';
import unzipper from 'unzipper';
import { createWorldService } from '../../src/services/worldService.js';
const extractZip = (zip, dest) =>
new Promise((res, rej) =>
createReadStream(zip).pipe(unzipper.Extract({ path: dest })).on('close', res).on('error', rej));
function makeService(mcDataPath) {
return createWorldService({
config: { mcDataPath, mcWorldName: 'worlds' },
dockerService: { getState: async () => 'exited', stop: async () => {}, start: async () => {} },
propertiesService: { read: async () => ({ 'level-name': 'Bedrock level' }) },
archiver,
extractZip,
});
}
async function mkWorld(dataPath, levelDatContent) {
const world = path.join(dataPath, 'worlds', 'Bedrock level');
await fsp.mkdir(path.join(world, 'db'), { recursive: true });
await fsp.writeFile(path.join(world, 'level.dat'), levelDatContent);
await fsp.writeFile(path.join(world, 'db', '000001.ldb'), 'blocks');
}
describe('world export/import bundles player data (full backup)', () => {
it('carries allowlist + history from one instance to another', async () => {
const tmp = await fsp.mkdtemp(path.join(os.tmpdir(), 'craftdock-bk-'));
const src = path.join(tmp, 'src');
const dst = path.join(tmp, 'dst');
// Source: a world + player-data files at the data root.
await mkWorld(src, 'SOURCE-WORLD');
await fsp.writeFile(path.join(src, 'allowlist.json'),
JSON.stringify([{ name: 'Lucasrguerra', ignoresPlayerLimit: false }]));
await fsp.writeFile(path.join(src, 'permissions.json'), JSON.stringify([]));
await fsp.writeFile(path.join(src, 'craftdock_players_history.json'), JSON.stringify(['Lucasrguerra']));
// Export.
const zipPath = path.join(tmp, 'world.zip');
const archive = await makeService(src).createDownloadStream();
const out = createWriteStream(zipPath);
archive.pipe(out);
await new Promise((res, rej) => { out.on('close', res); archive.on('error', rej); });
// Destination: a different, older world and an empty allowlist.
await mkWorld(dst, 'OLD-WORLD');
await fsp.writeFile(path.join(dst, 'allowlist.json'), JSON.stringify([]));
// Import.
await makeService(dst).importWorld(zipPath);
// World replaced.
expect(await fsp.readFile(path.join(dst, 'worlds', 'Bedrock level', 'level.dat'), 'utf8'))
.toBe('SOURCE-WORLD');
// Allowlist migrated (Lucasrguerra now known on the destination).
const allow = JSON.parse(await fsp.readFile(path.join(dst, 'allowlist.json'), 'utf8'));
expect(allow.map((e) => e.name)).toContain('Lucasrguerra');
// History migrated.
const hist = JSON.parse(await fsp.readFile(path.join(dst, 'craftdock_players_history.json'), 'utf8'));
expect(hist).toContain('Lucasrguerra');
// permissions.json migrated too.
await expect(fsp.access(path.join(dst, 'permissions.json'))).resolves.toBeUndefined();
});
});
@@ -0,0 +1,36 @@
import { describe, it, expect, vi } from 'vitest';
import path from 'node:path';
import { createWorldService } from '../../src/services/worldService.js';
describe('worldService.createDownloadStream', () => {
it('queues the world directory and finalizes the archive', async () => {
const archive = { directory: vi.fn(), file: vi.fn(), finalize: vi.fn(), on: vi.fn() };
const archiver = vi.fn().mockReturnValue(archive);
const svc = createWorldService({
config: { mcDataPath: '/data', mcWorldName: 'world' },
dockerService: {},
// no player-data files present -> access rejects, nothing extra is bundled
fs: { access: vi.fn().mockRejectedValue(new Error('ENOENT')) },
archiver,
});
const out = await svc.createDownloadStream();
expect(archiver).toHaveBeenCalledWith('zip', expect.any(Object));
expect(archive.directory).toHaveBeenCalledWith(path.join('/data', 'world'), 'world');
expect(archive.finalize).toHaveBeenCalled();
expect(out).toBe(archive);
});
it('bundles existing player-data files under craftdock/', async () => {
const archive = { directory: vi.fn(), file: vi.fn(), finalize: vi.fn(), on: vi.fn() };
const archiver = vi.fn().mockReturnValue(archive);
const svc = createWorldService({
config: { mcDataPath: '/data', mcWorldName: 'world' },
dockerService: {},
// only allowlist.json exists
fs: { access: vi.fn().mockImplementation(async (p) => { if (!p.endsWith('allowlist.json')) throw new Error('ENOENT'); }) },
archiver,
});
await svc.createDownloadStream();
expect(archive.file).toHaveBeenCalledWith(path.join('/data', 'allowlist.json'), { name: 'craftdock/allowlist.json' });
});
});
+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect, vi } from 'vitest';
import path from 'node:path';
import { createWorldService } from '../../src/services/worldService.js';
function deps() {
return {
config: { mcDataPath: '/data', mcWorldName: 'world' },
dockerService: {
getState: vi.fn().mockResolvedValue('running'),
stop: vi.fn().mockResolvedValue(),
start: vi.fn().mockResolvedValue(),
},
propertiesService: {
update: vi.fn().mockResolvedValue({}),
},
fs: { rm: vi.fn().mockResolvedValue() },
};
}
describe('worldService.regen', () => {
it('stops, removes world dir, restarts', async () => {
const d = deps();
const svc = createWorldService(d);
const res = await svc.regen();
expect(d.dockerService.stop).toHaveBeenCalled();
expect(d.fs.rm).toHaveBeenCalledWith(path.join('/data', 'world'), { recursive: true, force: true });
expect(d.dockerService.start).toHaveBeenCalled();
expect(res).toEqual({ ok: true });
});
it('does not stop if already stopped', async () => {
const d = deps();
d.dockerService.getState.mockResolvedValue('exited');
const svc = createWorldService(d);
await svc.regen();
expect(d.dockerService.stop).not.toHaveBeenCalled();
expect(d.dockerService.start).toHaveBeenCalled();
});
it('updates level-seed in propertiesService if seed is provided', async () => {
const d = deps();
const svc = createWorldService(d);
await svc.regen('12345');
expect(d.propertiesService.update).toHaveBeenCalledWith({ 'level-seed': '12345' });
});
});
+93
View File
@@ -0,0 +1,93 @@
import { describe, it, expect, vi } from 'vitest';
import path from 'node:path';
import { createWorldService } from '../../src/services/worldService.js';
function deps(extractOk = true) {
return {
config: { mcDataPath: '/data', mcWorldName: 'world' },
dockerService: {
getState: vi.fn().mockResolvedValue('running'),
stop: vi.fn().mockResolvedValue(),
start: vi.fn().mockResolvedValue(),
},
fs: {
rm: vi.fn().mockResolvedValue(),
rename: vi.fn().mockResolvedValue(),
mkdir: vi.fn().mockResolvedValue(),
readdir: vi.fn().mockResolvedValue(['level.dat']),
stat: vi.fn().mockResolvedValue({ isDirectory: () => false }),
},
// extractZip is injected for testability
extractZip: vi.fn().mockImplementation(async () => {
if (!extractOk) throw new Error('bad zip');
}),
};
}
describe('worldService.importWorld', () => {
it('extracts to temp, swaps, restarts on success', async () => {
const d = deps(true);
const svc = createWorldService(d);
const res = await svc.importWorld('/tmp/upload.zip');
expect(d.extractZip).toHaveBeenCalledWith('/tmp/upload.zip', path.join('/data', 'world.import'));
expect(d.fs.rm).toHaveBeenCalledWith(path.join('/data', 'world'), { recursive: true, force: true });
expect(d.fs.rename).toHaveBeenCalledWith(path.join('/data', 'world.import'), path.join('/data', 'world'));
expect(d.dockerService.start).toHaveBeenCalled();
expect(res).toEqual({ ok: true });
});
it('leaves live world intact and restarts server on bad zip', async () => {
const d = deps(false);
const svc = createWorldService(d);
await expect(svc.importWorld('/tmp/upload.zip')).rejects.toThrow('bad zip');
// live world never removed
expect(d.fs.rm).not.toHaveBeenCalledWith(path.join('/data', 'world'), expect.anything());
expect(d.fs.rename).not.toHaveBeenCalled();
// temp cleaned + server restarted
expect(d.fs.rm).toHaveBeenCalledWith(path.join('/data', 'world.import'), { recursive: true, force: true });
expect(d.dockerService.start).toHaveBeenCalled();
});
it('hoists nested directory for Java', async () => {
const d = deps(true);
d.fs.readdir = vi.fn().mockImplementation(async (dir) => {
if (dir.endsWith('world.import')) {
return ['my_world'];
}
if (dir.endsWith('my_world') || dir.endsWith('aligned_world')) {
return ['level.dat', 'db'];
}
return [];
});
d.fs.stat = vi.fn().mockImplementation(async (filePath) => {
return { isDirectory: () => filePath.endsWith('my_world') || filePath.endsWith('aligned_world') };
});
const svc = createWorldService(d);
await svc.importWorld('/tmp/upload.zip');
expect(d.fs.rename).toHaveBeenCalledWith(path.join('/data', 'world.import/my_world'), path.join('/data', 'world.import/aligned_world'));
expect(d.fs.rm).toHaveBeenCalledWith(path.join('/data', 'world.import/aligned_world'), { recursive: true, force: true });
});
it('aligns directory to level-name for Bedrock flat zip', async () => {
const d = deps(true);
d.config.mcWorldName = 'worlds';
d.propertiesService = {
read: vi.fn().mockResolvedValue({ 'level-name': 'My Bedrock World' })
};
d.fs.readdir = vi.fn().mockImplementation(async (dir) => {
if (dir.endsWith('worlds.import') || dir.endsWith('worlds.import.temp')) {
return ['level.dat', 'db'];
}
return [];
});
d.fs.stat = vi.fn().mockResolvedValue({ isDirectory: () => false });
const svc = createWorldService(d);
await svc.importWorld('/tmp/upload.zip');
expect(d.fs.mkdir).toHaveBeenCalledWith(path.join('/data', 'worlds.import/My Bedrock World'), { recursive: true });
});
// Bundled player-data (craftdock/) migration is covered end-to-end in
// worldServiceBackup.test.js (real archiver + unzipper round-trip).
});