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

+184
View File
@@ -0,0 +1,184 @@
import { describe, it, expect, vi } from 'vitest';
import { createBedrockAdapter } from '../../src/adapters/bedrockAdapter.js';
import { NotSupportedError } from '../../src/adapters/serverAdapter.js';
function make(sendImpl) {
const stdin = { send: vi.fn(sendImpl || (async () => 'ok')) };
return { stdin, adapter: createBedrockAdapter(stdin) };
}
describe('BedrockAdapter', () => {
it('whitelistAdd maps to "allowlist add"', async () => {
const { stdin, adapter } = make();
await adapter.whitelistAdd('steve');
expect(stdin.send).toHaveBeenCalledWith('allowlist add "steve"');
});
it('whitelistRemove maps to "allowlist remove"', async () => {
const { stdin, adapter } = make();
await adapter.whitelistRemove('steve');
expect(stdin.send).toHaveBeenCalledWith('allowlist remove "steve"');
});
it('ban throws NotSupportedError', async () => {
const { adapter } = make();
await expect(adapter.ban('x')).rejects.toBeInstanceOf(NotSupportedError);
});
it('does not advertise ban capability', () => {
const { adapter } = make();
expect(adapter.capabilities.has('ban')).toBe(false);
});
it('does not expose getSeed (Bedrock has no seed command; read from level.dat)', () => {
const { adapter } = make();
expect(adapter.getSeed).toBeUndefined();
});
it('getPlayerPosition parses querytarget response when it is clean JSON', async () => {
const { adapter } = make(async (cmd) => {
if (cmd.startsWith('querytarget')) {
return '[{"position":{"x":15.0,"y":70.0,"z":-30.0},"dimension":1}]';
}
return '';
});
const pos = await adapter.getPlayerPosition('alex');
expect(pos).toEqual({ x: 15, y: 70, z: -30, dimension: 'nether' });
});
it('getPlayerPosition parses querytarget response when it has a log/text prefix', async () => {
const { adapter } = make(async (cmd) => {
if (cmd.startsWith('querytarget')) {
return '[2026-07-18 23:08:38:396 INFO] Target data: [\n' +
' {\n' +
' "dimension" : 0,\n' +
' "position" : {\n' +
' "x" : 12.5,\n' +
' "y" : 64.0,\n' +
' "z" : -25.5\n' +
' }\n' +
' }\n' +
']';
}
return '';
});
const pos = await adapter.getPlayerPosition('alex');
expect(pos).toEqual({ x: 12.5, y: 64, z: -25.5, dimension: 'overworld' });
});
it('getPlayerPosition returns null when querytarget fails, and does not fall back to tp', async () => {
const { stdin, adapter } = make(async (cmd) => {
if (cmd.startsWith('querytarget')) {
return 'Unknown command or failure';
}
return 'Teleported alex to 12.5, 64, -25.5';
});
const pos = await adapter.getPlayerPosition('alex');
expect(pos).toBeNull();
expect(stdin.send).not.toHaveBeenCalledWith(expect.stringContaining('tp '));
});
it('whitelistOn maps to "allowlist on"', async () => {
const { stdin, adapter } = make();
await adapter.whitelistOn();
expect(stdin.send).toHaveBeenCalledWith('allowlist on');
});
it('whitelistOff maps to "allowlist off"', async () => {
const { stdin, adapter } = make();
await adapter.whitelistOff();
expect(stdin.send).toHaveBeenCalledWith('allowlist off');
});
it('whitelistList parses "allowlist list" output into array', async () => {
const { adapter } = make(async () => 'There are 2 allowlisted players: steve, alex');
const list = await adapter.whitelistList();
expect(list).toEqual(['steve', 'alex']);
});
it('whitelistList returns empty array when no players', async () => {
const { adapter } = make(async () => 'There are 0 allowlisted players:');
const list = await adapter.whitelistList();
expect(list).toEqual([]);
});
it('sendCommand sends command unmodified if no slash', async () => {
const { stdin, adapter } = make();
await adapter.sendCommand('gamerule showcoordinates true');
expect(stdin.send).toHaveBeenCalledWith('gamerule showcoordinates true');
});
it('sendCommand strips leading slash from command', async () => {
const { stdin, adapter } = make();
await adapter.sendCommand('/gamerule showcoordinates true');
expect(stdin.send).toHaveBeenCalledWith('gamerule showcoordinates true');
});
it('exposes whitelistOn/Off/List capabilities', () => {
const { adapter } = make();
expect(adapter.capabilities.has('whitelistOn')).toBe(true);
expect(adapter.capabilities.has('whitelistOff')).toBe(true);
expect(adapter.capabilities.has('whitelistList')).toBe(true);
});
it('saveHold sends "save hold" then polls "save query" until ready', async () => {
const { stdin, adapter } = make(async (cmd) =>
cmd === 'save query' ? 'Data saved. Files are now ready to be copied.' : 'ok');
const ok = await adapter.saveHold();
expect(ok).toBe(true);
expect(stdin.send).toHaveBeenCalledWith('save hold');
expect(stdin.send).toHaveBeenCalledWith('save query');
});
it('saveResume sends "save resume"', async () => {
const { stdin, adapter } = make();
await adapter.saveResume();
expect(stdin.send).toHaveBeenCalledWith('save resume');
});
it('queryUniqueId extracts the uniqueId from querytarget output', async () => {
const { adapter } = make(async (cmd) =>
cmd.startsWith('querytarget')
? '[{"dimension":0,"position":{"x":1,"y":2,"z":3},"uniqueId":"-8589934591"}]'
: 'ok');
expect(await adapter.queryUniqueId('alex')).toBe('-8589934591');
});
it('queryUniqueId extracts decimal uniqueId from noisy log output', async () => {
const { adapter } = make(async (cmd) =>
cmd.startsWith('querytarget')
? '[2026-07-22 22:43:05:123 INFO] Target data: [{"dimension":0,"position":{"x":100,"y":64,"z":-200},"uniqueId":2535407895138987}]'
: 'ok');
expect(await adapter.queryUniqueId('Lucasrguerra')).toBe('2535407895138987');
});
it('queryUniqueId parses the real multi-line 1.26 output (UUID uniqueId, id present)', async () => {
// Captured verbatim from a live bedrock 1.26.33.2 server
const real = [
'[2026-07-23 02:40:43:441 INFO] Target data: [',
' {',
' "dimension" : 0,',
' "id" : -64424509439,',
' "position" : {',
' "x" : 79.52253723144531,',
' "y" : 98.62001037597656,',
' "z" : 14.37582015991211',
' },',
' "uniqueId" : "657cb1d4-99dd-3123-b27f-d0c27df79710",',
' "yRot" : -82.85218048095703',
' }',
']',
].join('\n');
const { adapter } = make(async (cmd) => (cmd.startsWith('querytarget') ? real : 'ok'));
expect(await adapter.queryUniqueId('Lucasrguerra')).toBe('657cb1d4-99dd-3123-b27f-d0c27df79710');
expect(await adapter.getPlayerPosition('Lucasrguerra')).toEqual({
x: 79.52253723144531, y: 98.62001037597656, z: 14.37582015991211, dimension: 'overworld',
});
});
it('queryUniqueId returns null when querytarget fails', async () => {
const { adapter } = make(async () => 'Unknown command');
expect(await adapter.queryUniqueId('alex')).toBeNull();
});
});
+102
View File
@@ -0,0 +1,102 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import pkg from 'leveldb-zlib';
import nbt from 'prismarine-nbt';
import { createBedrockPlayerFile, parsePlayerBuffer } from '../../src/adapters/playerFile/bedrockPlayerFile.js';
const { LevelDB } = pkg;
const here = path.dirname(fileURLToPath(import.meta.url));
const FIXTURE = path.join(here, '../fixtures/bedrock-player.nbt');
const UUID = '1f633c09-f1f1-481c-b811-1cfa15b21973';
// Real-world shape: querytarget's uniqueId is the player's MsaId, and the DB
// holds a mapping record player_<MsaId> → { MsaId, SelfSignedId, ServerId }.
const MSA_ID = '657cb1d4-99dd-3123-b27f-d0c27df79710';
const SELF_SIGNED_ID = 'a257319c-50e9-3aad-aef3-8fc3e0d4e4c2';
function mappingRecord() {
const str = (v) => ({ type: 'string', value: v });
return nbt.writeUncompressed({
type: 'compound',
name: '',
value: {
MsaId: str(MSA_ID),
SelfSignedId: str(SELF_SIGNED_ID),
ServerId: str('player_server_' + UUID),
},
}, 'little');
}
let tmp, config;
beforeAll(async () => {
const buf = fs.readFileSync(FIXTURE);
tmp = await fsp.mkdtemp(path.join(os.tmpdir(), 'cd-bpf-'));
const dbPath = path.join(tmp, 'worlds', 'Bedrock level', 'db');
await fsp.mkdir(dbPath, { recursive: true });
// Build a real (Mojang-format) LevelDB holding the captured player NBT.
const db = new LevelDB(dbPath, { createIfMissing: true });
await db.open();
await db.put(Buffer.from('player_server_' + UUID, 'latin1'), buf);
await db.put(Buffer.from('player_' + MSA_ID, 'latin1'), mappingRecord());
await db.put(Buffer.from('player_' + SELF_SIGNED_ID, 'latin1'), mappingRecord());
await db.close();
config = { mcDataPath: tmp, mcWorldName: 'worlds' };
});
afterAll(async () => { await fsp.rm(tmp, { recursive: true, force: true }); });
describe('bedrockPlayerFile', () => {
it('parsePlayerBuffer decodes the captured real-world fixture', async () => {
const d = await parsePlayerBuffer(fs.readFileSync(FIXTURE));
expect(d.position).not.toBeNull();
expect(d.health.max).toBe(20);
expect(typeof d.food).toBe('number');
expect(Array.isArray(d.inventory)).toBe(true);
expect(d.uniqueId).toBe('-8589934591');
});
it('readPlayer returns normalized data for an existing uuid', async () => {
const adapter = createBedrockPlayerFile(config);
const d = await adapter.readPlayer(UUID);
expect(d.uniqueId).toBe('-8589934591');
expect(d.position).not.toBeNull();
});
it('readPlayer returns null for a missing uuid', async () => {
const adapter = createBedrockPlayerFile(config);
expect(await adapter.readPlayer('00000000-0000-0000-0000-000000000000')).toBeNull();
});
it('findByUniqueId locates the player by UniqueID (int64 or UUID string format)', async () => {
const adapter = createBedrockPlayerFile(config);
const hit = await adapter.findByUniqueId('-8589934591');
expect(hit).not.toBeNull();
expect(hit.uuid).toBe(UUID);
// Modern Bedrock servers output uniqueId in UUID hex string format via querytarget:
// e.g. "657cb1d4-99dd-3123-b27f-d0c27df79710" or corresponding int64 (-8589934591)
const hit2 = await adapter.findByUniqueId('00000000-0000-0000-ffff-fffe00000001');
expect(hit2).not.toBeNull();
expect(hit2.uuid).toBe(UUID);
});
it('findByUniqueId resolves via the player_<uuid> ServerId mapping record', async () => {
const adapter = createBedrockPlayerFile(config);
// querytarget uniqueId (= MsaId) — the case seen against a live server
const hit = await adapter.findByUniqueId(MSA_ID);
expect(hit).not.toBeNull();
expect(hit.uuid).toBe(UUID);
// SelfSignedId mapping record also resolves
const hit2 = await adapter.findByUniqueId(SELF_SIGNED_ID);
expect(hit2.uuid).toBe(UUID);
});
it('listServerUuids enumerates player_server_ keys', async () => {
const adapter = createBedrockPlayerFile(config);
expect(await adapter.listServerUuids()).toContain(UUID);
});
});
+26
View File
@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest';
import { createAdapter, isBedrock } from '../../src/adapters/index.js';
const deps = {
rconService: { send: async () => 'r' },
stdinService: { send: async () => 's' },
};
describe('adapter factory', () => {
it('isBedrock detects BEDROCK type case-insensitively', () => {
expect(isBedrock('BEDROCK')).toBe(true);
expect(isBedrock('bedrock')).toBe(true);
expect(isBedrock('PAPER')).toBe(false);
expect(isBedrock(null)).toBe(false);
});
it('creates a java adapter for PAPER', () => {
const a = createAdapter('PAPER', deps);
expect(a.capabilities.has('ban')).toBe(true);
});
it('creates a bedrock adapter for BEDROCK', () => {
const a = createAdapter('BEDROCK', deps);
expect(a.capabilities.has('ban')).toBe(false);
});
});
+108
View File
@@ -0,0 +1,108 @@
import { describe, it, expect, vi } from 'vitest';
import { createJavaAdapter } from '../../src/adapters/javaAdapter.js';
function make(sendImpl) {
const rcon = { send: vi.fn(sendImpl || (async () => 'ok')) };
return { rcon, adapter: createJavaAdapter(rcon) };
}
describe('JavaAdapter', () => {
it('whitelistAdd issues "whitelist add"', async () => {
const { rcon, adapter } = make();
await adapter.whitelistAdd('steve');
expect(rcon.send).toHaveBeenCalledWith('whitelist add steve');
});
it('ban issues "ban" with reason', async () => {
const { rcon, adapter } = make();
await adapter.ban('griefer', 'no griefing');
expect(rcon.send).toHaveBeenCalledWith('ban griefer no griefing');
});
it('give issues "give" with item and count', async () => {
const { rcon, adapter } = make();
await adapter.give('steve', 'minecraft:diamond', 5);
expect(rcon.send).toHaveBeenCalledWith('give steve minecraft:diamond 5');
});
it('listPlayers parses rcon output', async () => {
const { adapter } = make(async () => 'There are 1 of a max of 20 players online: steve');
expect(await adapter.listPlayers()).toEqual({ online: 1, max: 20, players: ['steve'] });
});
it('exposes JAVA capabilities', () => {
const { adapter } = make();
expect(adapter.capabilities.has('ban')).toBe(true);
});
it('getSeed parses seed correctly', async () => {
const { adapter } = make(async () => 'Seed: [123456789]');
const seed = await adapter.getSeed();
expect(seed).toBe('123456789');
});
it('getPlayerPosition parses coordinates and dimension correctly', async () => {
let callCount = 0;
const { rcon, adapter } = make(async (cmd) => {
callCount++;
if (cmd.startsWith('tp')) {
return 'Teleported steve to 10.5, 64, -20.3';
}
if (cmd.startsWith('data get')) {
return 'steve has the following entity data: "minecraft:overworld"';
}
return '';
});
const pos = await adapter.getPlayerPosition('steve');
expect(pos).toEqual({ x: 10.5, y: 64, z: -20.3, dimension: 'overworld' });
});
it('whitelistOn maps to "whitelist on"', async () => {
const { rcon, adapter } = make();
await adapter.whitelistOn();
expect(rcon.send).toHaveBeenCalledWith('whitelist on');
});
it('whitelistOff maps to "whitelist off"', async () => {
const { rcon, adapter } = make();
await adapter.whitelistOff();
expect(rcon.send).toHaveBeenCalledWith('whitelist off');
});
it('whitelistList parses "whitelist list" output into array', async () => {
const { adapter } = make(async () => 'There are 3 whitelisted players: steve, alex, notch');
const list = await adapter.whitelistList();
expect(list).toEqual(['steve', 'alex', 'notch']);
});
it('whitelistList returns empty array when no players', async () => {
const { adapter } = make(async () => 'There are 0 whitelisted players:');
const list = await adapter.whitelistList();
expect(list).toEqual([]);
});
it('sendCommand sends command unmodified if no slash', async () => {
const { rcon, adapter } = make();
await adapter.sendCommand('gamerule showcoordinates true');
expect(rcon.send).toHaveBeenCalledWith('gamerule showcoordinates true');
});
it('sendCommand strips leading slash from command', async () => {
const { rcon, adapter } = make();
await adapter.sendCommand('/gamerule showcoordinates true');
expect(rcon.send).toHaveBeenCalledWith('gamerule showcoordinates true');
});
it('exposes whitelistOn/Off/List capabilities', () => {
const { adapter } = make();
expect(adapter.capabilities.has('whitelistOn')).toBe(true);
expect(adapter.capabilities.has('whitelistOff')).toBe(true);
expect(adapter.capabilities.has('whitelistList')).toBe(true);
});
it('forceSave issues "save-all flush"', async () => {
const { rcon, adapter } = make();
await adapter.forceSave();
expect(rcon.send).toHaveBeenCalledWith('save-all flush');
});
});
+57
View File
@@ -0,0 +1,57 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fsp from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import zlib from 'node:zlib';
import nbt from 'prismarine-nbt';
import { createJavaPlayerFile } from '../../src/adapters/playerFile/javaPlayerFile.js';
const UUID = '11111111-2222-3333-4444-555555555555';
let tmp, config;
beforeAll(async () => {
const doc = {
type: 'compound', name: '', value: {
Pos: { type: 'list', value: { type: 'double', value: [10.5, 64, -20.3] } },
Dimension: { type: 'string', value: 'minecraft:the_end' },
Health: { type: 'float', value: 18 },
foodLevel: { type: 'int', value: 15 },
Inventory: {
type: 'list', value: {
type: 'compound', value: [{
Slot: { type: 'byte', value: 0 },
id: { type: 'string', value: 'minecraft:dirt' },
Count: { type: 'byte', value: 64 },
}],
},
},
playerGameType: { type: 'int', value: 0 },
XpLevel: { type: 'int', value: 3 },
},
};
const dat = zlib.gzipSync(nbt.writeUncompressed(doc, 'big'));
tmp = await fsp.mkdtemp(path.join(os.tmpdir(), 'cd-jpf-'));
const pdir = path.join(tmp, 'world', 'playerdata');
await fsp.mkdir(pdir, { recursive: true });
await fsp.writeFile(path.join(pdir, `${UUID}.dat`), dat);
config = { mcDataPath: tmp, mcWorldName: 'world' };
});
afterAll(async () => { if (tmp) await fsp.rm(tmp, { recursive: true, force: true }); });
describe('javaPlayerFile', () => {
it('reads and normalizes a playerdata .dat', async () => {
const d = await createJavaPlayerFile(config).readPlayer(UUID);
expect(d.position).toEqual({ x: 10.5, y: 64, z: -20.3 });
expect(d.dimension).toBe('end');
expect(d.health).toEqual({ current: 18, max: 20 });
expect(d.food).toBe(15);
expect(d.inventory).toEqual([{ slot: 0, name: 'minecraft:dirt', count: 64 }]);
expect(d.gamemode).toBe('survival');
});
it('returns null when the player has no .dat', async () => {
const d = await createJavaPlayerFile(config).readPlayer('00000000-0000-0000-0000-000000000000');
expect(d).toBeNull();
});
});
+104
View File
@@ -0,0 +1,104 @@
import { describe, it, expect } from 'vitest';
import { normalizeBedrock, normalizeJava, int64ToString } from '../../src/adapters/playerFile/nbtPlayer.js';
describe('int64ToString', () => {
it('combines [high, low] int32 pair like prismarine-nbt longs', () => {
expect(int64ToString([-2, 1])).toBe('-8589934591');
expect(int64ToString([0, 5])).toBe('5');
});
it('passes through number/bigint', () => {
expect(int64ToString(42)).toBe('42');
expect(int64ToString(7n)).toBe('7');
});
});
describe('normalizeBedrock', () => {
it('extracts position, dimension, health, hunger, inventory, uniqueId', () => {
const d = normalizeBedrock({
Pos: [1.5, 2, 3], DimensionId: 1,
Attributes: [
{ Name: 'minecraft:health', Current: 9.5, Max: 20 },
{ Name: 'minecraft:player.hunger', Current: 17 },
],
Inventory: [
{ Slot: 0, Name: 'minecraft:iron_pickaxe', Count: 1 },
{ Slot: 1, Name: '', Count: 0 },
],
PlayerGameMode: 1, PlayerLevel: 10, UniqueID: [-2, 1],
});
expect(d.position).toEqual({ x: 1.5, y: 2, z: 3 });
expect(d.dimension).toBe('nether');
expect(d.health).toEqual({ current: 9.5, max: 20 });
expect(d.food).toBe(17);
expect(d.inventory).toEqual([{ slot: 0, name: 'minecraft:iron_pickaxe', count: 1 }]);
expect(d.gamemode).toBe('creative');
expect(d.xp).toEqual({ level: 10 });
expect(d.uniqueId).toBe('-8589934591');
});
it('extracts armor (head/chest/legs/feet) and offhand from their own NBT lists', () => {
const d = normalizeBedrock({
Armor: [
{ Name: 'minecraft:iron_helmet', Count: 1 },
{ Name: 'minecraft:iron_chestplate', Count: 1 },
{ Name: '', Count: 0 },
{ Name: 'minecraft:leather_boots', Count: 1 },
{ Name: '', Count: 0 }, // 5th body slot on some versions — ignored
],
Offhand: [{ Name: 'minecraft:shield', Count: 1 }],
});
expect(d.armor).toEqual({
head: { name: 'minecraft:iron_helmet', count: 1 },
chest: { name: 'minecraft:iron_chestplate', count: 1 },
legs: null,
feet: { name: 'minecraft:leather_boots', count: 1 },
});
expect(d.offhand).toEqual({ name: 'minecraft:shield', count: 1 });
});
it('yields empty armor/offhand when the lists are absent or empty-named', () => {
const d = normalizeBedrock({});
expect(d.armor).toEqual({ head: null, chest: null, legs: null, feet: null });
expect(d.offhand).toBeNull();
});
});
describe('normalizeJava', () => {
it('extracts fields from a java playerdata shape', () => {
const d = normalizeJava({
Pos: [10.5, 64, -20.3], Dimension: 'minecraft:the_end',
Health: 18.0, foodLevel: 15,
Inventory: [{ Slot: 0, id: 'minecraft:dirt', Count: 64 }],
playerGameType: 0, XpLevel: 3,
});
expect(d.position).toEqual({ x: 10.5, y: 64, z: -20.3 });
expect(d.dimension).toBe('end');
expect(d.health).toEqual({ current: 18, max: 20 });
expect(d.food).toBe(15);
expect(d.inventory).toEqual([{ slot: 0, name: 'minecraft:dirt', count: 64 }]);
expect(d.gamemode).toBe('survival');
expect(d.xp).toEqual({ level: 3 });
});
it('splits armor (slots 100-103) and offhand (-106) out of the java Inventory list', () => {
const d = normalizeJava({
Inventory: [
{ Slot: 0, id: 'minecraft:dirt', Count: 64 },
{ Slot: 100, id: 'minecraft:iron_boots', Count: 1 },
{ Slot: 101, id: 'minecraft:iron_leggings', Count: 1 },
{ Slot: 102, id: 'minecraft:iron_chestplate', Count: 1 },
{ Slot: 103, id: 'minecraft:iron_helmet', Count: 1 },
{ Slot: -106, id: 'minecraft:shield', Count: 1 },
],
});
// armor/offhand are NOT part of the regular inventory grid
expect(d.inventory).toEqual([{ slot: 0, name: 'minecraft:dirt', count: 64 }]);
expect(d.armor).toEqual({
head: { name: 'minecraft:iron_helmet', count: 1 },
chest: { name: 'minecraft:iron_chestplate', count: 1 },
legs: { name: 'minecraft:iron_leggings', count: 1 },
feet: { name: 'minecraft:iron_boots', count: 1 },
});
expect(d.offhand).toEqual({ name: 'minecraft:shield', count: 1 });
});
});
+18
View File
@@ -0,0 +1,18 @@
import { describe, it, expect } from 'vitest';
import { createPlayerFileAdapter } from '../../src/adapters/playerFile/index.js';
const config = { mcDataPath: '/data', mcWorldName: 'worlds' };
describe('createPlayerFileAdapter', () => {
it('returns the bedrock adapter (with findByUniqueId) for bedrock', () => {
const a = createPlayerFileAdapter('bedrock', config);
expect(typeof a.readPlayer).toBe('function');
expect(typeof a.findByUniqueId).toBe('function');
});
it('returns the java adapter for java', () => {
const a = createPlayerFileAdapter('java', config);
expect(typeof a.readPlayer).toBe('function');
expect(a.findByUniqueId).toBeUndefined();
});
});
+62
View File
@@ -0,0 +1,62 @@
import { describe, it, expect } from 'vitest';
import { parsePlayerList, parseWhitelistList, CAPABILITIES } from '../../src/adapters/serverAdapter.js';
describe('parsePlayerList', () => {
it('parses Java list output', () => {
const r = parsePlayerList(
'There are 2 of a max of 20 players online: steve, alex'
);
expect(r).toEqual({ online: 2, max: 20, players: ['steve', 'alex'] });
});
it('parses Bedrock list output', () => {
const r = parsePlayerList('There are 1/10 players online:\nnotch');
expect(r).toEqual({ online: 1, max: 10, players: ['notch'] });
});
it('handles empty server', () => {
const r = parsePlayerList('There are 0 of a max of 20 players online:');
expect(r).toEqual({ online: 0, max: 20, players: [] });
});
});
describe('CAPABILITIES', () => {
it('java supports ban, bedrock does not', () => {
expect(CAPABILITIES.JAVA.has('ban')).toBe(true);
expect(CAPABILITIES.BEDROCK.has('ban')).toBe(false);
});
});
describe('parseWhitelistList', () => {
it('parses Java style text whitelist list', () => {
const res = parseWhitelistList('There are 2 whitelisted players: steve, alex');
expect(res).toEqual(['steve', 'alex']);
});
it('parses Bedrock style JSON allowlist list', () => {
const res = parseWhitelistList(
'41:55:938 INFO] ###* {"command":"allowlist", "result":[{"ignoresPlayerLimit":false,"name":"Lucasrguerra","xuid":"2535407895138987"}]} *###'
);
expect(res).toEqual(['Lucasrguerra']);
});
it('returns empty for an empty Bedrock allowlist (result: null), not garbage', () => {
// This is the exact line that produced ghost "names" from log fragments.
const res = parseWhitelistList(
'[2026-07-18 22:53:21:185 INFO] ###* {"command":"allowlist","result":null} *###'
);
expect(res).toEqual([]);
});
it('returns empty for a Bedrock allowlist with an empty array', () => {
expect(parseWhitelistList('###* {"command":"allowlist","result":[]} *###')).toEqual([]);
});
it('never scrapes names out of an unrelated log line with a colon', () => {
expect(parseWhitelistList('[2026-07-18 22:53:23:695 ERROR] Syntax error: Unexpected token')).toEqual([]);
});
it('returns empty (not [""]) for the Java empty-whitelist message', () => {
expect(parseWhitelistList('There are no whitelisted players')).toEqual([]);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { describe, it, expect, vi } from 'vitest';
import { createAppState } from '../src/appState.js';
const deps = () => ({
config: { mcEdition: 'auto' },
dockerService: { inspect: vi.fn().mockResolvedValue({ type: 'PAPER' }) },
rconService: { send: async () => 'r' },
stdinService: { send: async () => 's' },
});
describe('appState', () => {
it('resolves a java adapter when TYPE is PAPER', async () => {
const state = createAppState(deps());
const a = await state.getAdapter();
expect(a.capabilities.has('ban')).toBe(true);
});
it('honors forced bedrock edition without inspecting', async () => {
const d = deps();
d.config.mcEdition = 'bedrock';
const state = createAppState(d);
const a = await state.getAdapter();
expect(a.capabilities.has('ban')).toBe(false);
expect(d.dockerService.inspect).not.toHaveBeenCalled();
});
it('does not cache a fallback when TYPE is unknown, then re-resolves once known', async () => {
const d = deps();
// First inspect (container stopped) yields no type; second yields BEDROCK.
d.dockerService.inspect = vi
.fn()
.mockResolvedValueOnce({ type: null })
.mockResolvedValueOnce({ type: 'BEDROCK' });
const state = createAppState(d);
const first = await state.getAdapter();
expect(first.capabilities.has('ban')).toBe(true); // java fallback, not cached
const second = await state.getAdapter();
expect(second.capabilities.has('ban')).toBe(false); // now correctly bedrock
expect(d.dockerService.inspect).toHaveBeenCalledTimes(2);
});
it('caches once a definitive TYPE is resolved', async () => {
const d = deps(); // inspect always returns PAPER
const state = createAppState(d);
await state.getAdapter();
await state.getAdapter();
expect(d.dockerService.inspect).toHaveBeenCalledTimes(1);
});
});
+56
View File
@@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest';
import { loadConfig } from '../src/config.js';
const base = {
ADMIN_PASSWORD_HASH: 'hash',
SESSION_SECRET: 'secret',
};
describe('loadConfig', () => {
it('applies defaults for optional vars', () => {
const c = loadConfig(base);
expect(c.mcContainerName).toBe('craftdock-mc-server');
expect(c.rconPort).toBe(25575);
expect(c.port).toBe(3000);
expect(c.mcEdition).toBe('auto');
expect(c.maxUploadBytes).toBe(1024 * 1024 * 1024);
});
it('throws when required vars are missing', () => {
expect(() => loadConfig({})).toThrow(/ADMIN_PASSWORD_HASH/);
});
it('does not require RCON_PASSWORD (Java-only), defaulting it to empty', () => {
const c = loadConfig(base);
expect(c.rconPassword).toBe('');
});
it('accepts a base64-encoded hash when the raw hash is absent', () => {
const realHash = '$2a$12$YjrtHh2BNcUT0P76L2ZUF.mCtxzUwN.gZ4siUqji2oxrDbIXRdvfe';
const b64 = Buffer.from(realHash, 'utf8').toString('base64');
const c = loadConfig({ SESSION_SECRET: 's', ADMIN_PASSWORD_HASH_B64: b64 });
expect(c.adminPasswordHash).toBe(realHash);
});
it('prefers the raw hash over the base64 one when both are set', () => {
const b64 = Buffer.from('other', 'utf8').toString('base64');
const c = loadConfig({ SESSION_SECRET: 's', ADMIN_PASSWORD_HASH: 'raw', ADMIN_PASSWORD_HASH_B64: b64 });
expect(c.adminPasswordHash).toBe('raw');
});
it('throws when neither raw nor base64 hash is provided', () => {
expect(() => loadConfig({ SESSION_SECRET: 's' })).toThrow(/ADMIN_PASSWORD_HASH/);
});
it('coerces numeric env vars', () => {
const c = loadConfig({ ...base, RCON_PORT: '25580', PORT: '8080' });
expect(c.rconPort).toBe(25580);
expect(c.port).toBe(8080);
});
it('falls back to defaults for empty string or non-numeric numeric env vars', () => {
const c = loadConfig({ ...base, RCON_PORT: '', PORT: 'abc' });
expect(c.rconPort).toBe(25575);
expect(c.port).toBe(3000);
});
});
Binary file not shown.
+57
View File
@@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest';
import { createLogger } from '../src/logger.js';
describe('logger', () => {
it('formats text logs with timestamp, level, component and message', () => {
let output = '';
const stream = {
write: (data) => { output += data; },
};
const log = createLogger('test-comp', { level: 'info', format: 'text', stream });
log.info('hello world', { foo: 'bar' });
expect(output).toContain('[INFO ] [test-comp] hello world {"foo":"bar"}');
});
it('formats JSON logs when format is json', () => {
let output = '';
const stream = {
write: (data) => { output += data; },
};
const log = createLogger('json-comp', { level: 'info', format: 'json', stream });
log.info('json message', { count: 42 });
const parsed = JSON.parse(output.trim());
expect(parsed.component).toBe('json-comp');
expect(parsed.level).toBe('info');
expect(parsed.msg).toBe('json message');
expect(parsed.meta).toEqual({ count: 42 });
});
it('filters out logs above threshold level', () => {
let output = '';
const stream = {
write: (data) => { output += data; },
};
const log = createLogger('threshold-comp', { level: 'warn', format: 'text', stream });
log.info('should be ignored');
log.warn('should be logged');
expect(output).not.toContain('should be ignored');
expect(output).toContain('should be logged');
});
it('serializes Error objects correctly', () => {
let output = '';
const stream = {
write: (data) => { output += data; },
};
const log = createLogger('error-comp', { level: 'error', format: 'json', stream });
const err = new Error('something went wrong');
log.error('failure', { err });
const parsed = JSON.parse(output.trim());
expect(parsed.meta.err.error).toBe('something went wrong');
expect(parsed.meta.err.stack).toBeDefined();
});
});
+61
View File
@@ -0,0 +1,61 @@
import { describe, it, expect, vi } from 'vitest';
import { requireAuth, socketAuth } from '../../src/middleware/auth.js';
function mockRes() {
return {
statusCode: 200,
body: null,
status(c) { this.statusCode = c; return this; },
json(b) { this.body = b; return this; },
};
}
describe('requireAuth', () => {
it('calls next when session is authed', () => {
const next = vi.fn();
requireAuth({ session: { authed: true } }, mockRes(), next);
expect(next).toHaveBeenCalledOnce();
});
it('returns 401 when not authed', () => {
const next = vi.fn();
const res = mockRes();
requireAuth({ session: {} }, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.statusCode).toBe(401);
expect(res.body).toEqual({ error: 'unauthorized' });
});
});
describe('socketAuth', () => {
it('calls next with no error when session is authed', () => {
const next = vi.fn();
const socket = { request: {} };
const fakeSessionMiddleware = (req, res, callback) => {
req.session = { authed: true };
callback();
};
const middleware = socketAuth(fakeSessionMiddleware);
middleware(socket, next);
expect(next).toHaveBeenCalledOnce();
expect(next).toHaveBeenCalledWith();
});
it('calls next with unauthorized error when not authed', () => {
const next = vi.fn();
const socket = { request: {} };
const fakeSessionMiddleware = (req, res, callback) => {
req.session = {};
callback();
};
const middleware = socketAuth(fakeSessionMiddleware);
middleware(socket, next);
expect(next).toHaveBeenCalledOnce();
expect(next).toHaveBeenCalledWith(expect.any(Error));
expect(next.mock.calls[0][0].message).toBe('unauthorized');
});
});
+37
View File
@@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest';
import express from 'express';
import session from 'express-session';
import request from 'supertest';
import { createAuthRouter } from '../../src/routes/auth.js';
function makeApp() {
const app = express();
app.use(express.json());
app.use(session({ secret: 't', resave: false, saveUninitialized: false }));
const authService = { verifyPassword: async (p) => p === 'right' };
app.use('/api/auth', createAuthRouter(authService));
return app;
}
describe('auth routes', () => {
it('logs in with correct password and reports authed', async () => {
const agent = request.agent(makeApp());
const login = await agent.post('/api/auth/login').send({ password: 'right' });
expect(login.status).toBe(200);
const me = await agent.get('/api/auth/me');
expect(me.body).toEqual({ authed: true });
});
it('rejects wrong password', async () => {
const res = await request(makeApp()).post('/api/auth/login').send({ password: 'no' });
expect(res.status).toBe(401);
});
it('logs out', async () => {
const agent = request.agent(makeApp());
await agent.post('/api/auth/login').send({ password: 'right' });
await agent.post('/api/auth/logout');
const me = await agent.get('/api/auth/me');
expect(me.body).toEqual({ authed: false });
});
});
+149
View File
@@ -0,0 +1,149 @@
import { describe, it, expect, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
import { createPlayersRouter } from '../../src/routes/players.js';
import { NotSupportedError } from '../../src/adapters/serverAdapter.js';
function makeApp(adapterOverrides = {}, { propertiesOverrides } = {}) {
const adapter = {
capabilities: new Set(['whitelistAdd', 'whitelistRemove', 'whitelistOn', 'whitelistOff', 'whitelistList', 'ban', 'give']),
_edition: 'java',
listPlayers: vi.fn().mockResolvedValue({ online: 0, max: 20, players: [] }),
whitelistAdd: vi.fn().mockResolvedValue('Added steve'),
whitelistRemove: vi.fn().mockResolvedValue('Removed steve'),
whitelistOn: vi.fn().mockResolvedValue('Whitelist enabled'),
whitelistOff: vi.fn().mockResolvedValue('Whitelist disabled'),
whitelistList: vi.fn().mockResolvedValue(['steve', 'alex']),
ban: vi.fn().mockResolvedValue('Banned'),
give: vi.fn().mockResolvedValue('Gave'),
...adapterOverrides,
};
const propertiesService = {
read: vi.fn().mockResolvedValue({ 'white-list': 'true' }),
update: vi.fn().mockResolvedValue({}),
...propertiesOverrides,
};
const appState = { getAdapter: vi.fn().mockResolvedValue(adapter) };
const app = express();
app.use(express.json());
app.use('/api/players', createPlayersRouter({ appState, propertiesService, config: { mcDataPath: '/data' } }));
return { app, adapter, propertiesService };
}
describe('players routes', () => {
it('rejects a name-requiring action with an empty name (does not call the adapter)', async () => {
const { app, adapter } = makeApp();
const res = await request(app).post('/api/players/whitelistRemove').send({ name: ' ' });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'name_required' });
expect(adapter.whitelistRemove).not.toHaveBeenCalled();
});
it('allows name-less actions (whitelistOn) without a name', async () => {
const { app, adapter } = makeApp();
const res = await request(app).post('/api/players/whitelistOn').send({});
expect(res.status).toBe(200);
expect(adapter.whitelistOn).toHaveBeenCalled();
});
it('GET / returns players and capability list (no embedded directory)', async () => {
const { app } = makeApp();
const res = await request(app).get('/api/players');
expect(res.body.capabilities).toContain('ban');
expect(res.body.players).toEqual({ online: 0, max: 20, players: [] });
expect(res.body.players.directory).toBeUndefined();
});
it('GET /directory returns a paginated shape with online annotation', async () => {
const { app } = makeApp();
const res = await request(app).get('/api/players/directory?page=1&pageSize=25');
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ page: 1, pageSize: 25, total: expect.any(Number) });
expect(Array.isArray(res.body.items)).toBe(true);
});
it('GET /detail requires an xuid', async () => {
const { app } = makeApp();
const res = await request(app).get('/api/players/detail');
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'xuid_required' });
});
it('GET /detail returns 404 for an unknown player', async () => {
const { app } = makeApp();
const res = await request(app).get('/api/players/detail?xuid=doesnotexist');
expect(res.status).toBe(404);
});
it('GET / returns whitelistEnabled and whitelist', async () => {
const { app } = makeApp();
const res = await request(app).get('/api/players');
expect(res.body.whitelistEnabled).toBe(true);
expect(res.body.whitelist).toEqual(['steve', 'alex']);
});
it('GET / returns whitelistEnabled=false when property is false', async () => {
const { app } = makeApp({}, {
propertiesOverrides: { read: vi.fn().mockResolvedValue({ 'white-list': 'false' }) },
});
const res = await request(app).get('/api/players');
expect(res.body.whitelistEnabled).toBe(false);
});
it('GET / reads allow-list property for bedrock edition', async () => {
const { app } = makeApp({ _edition: 'bedrock' }, {
propertiesOverrides: { read: vi.fn().mockResolvedValue({ 'allow-list': 'true' }) },
});
const res = await request(app).get('/api/players');
expect(res.body.whitelistEnabled).toBe(true);
});
it('POST /whitelistAdd delegates with name', async () => {
const { app, adapter } = makeApp();
const res = await request(app).post('/api/players/whitelistAdd').send({ name: 'steve' });
expect(res.body).toEqual({ ok: true, output: 'Added steve' });
expect(adapter.whitelistAdd).toHaveBeenCalledWith('steve');
});
it('POST /whitelistOn calls adapter and persists to properties', async () => {
const { app, adapter, propertiesService } = makeApp();
const res = await request(app).post('/api/players/whitelistOn').send({});
expect(res.body.ok).toBe(true);
expect(adapter.whitelistOn).toHaveBeenCalled();
expect(propertiesService.update).toHaveBeenCalledWith({ 'white-list': 'true' });
});
it('POST /whitelistOff calls adapter and persists to properties', async () => {
const { app, adapter, propertiesService } = makeApp();
const res = await request(app).post('/api/players/whitelistOff').send({});
expect(res.body.ok).toBe(true);
expect(adapter.whitelistOff).toHaveBeenCalled();
expect(propertiesService.update).toHaveBeenCalledWith({ 'white-list': 'false' });
});
it('POST /whitelistOn for bedrock persists allow-list property', async () => {
const { app, adapter, propertiesService } = makeApp({ _edition: 'bedrock' });
const res = await request(app).post('/api/players/whitelistOn').send({});
expect(res.body.ok).toBe(true);
expect(propertiesService.update).toHaveBeenCalledWith({ 'allow-list': 'true' });
});
it('POST /give passes item and count', async () => {
const { app, adapter } = makeApp();
await request(app).post('/api/players/give').send({ name: 'steve', item: 'minecraft:dirt', count: 3 });
expect(adapter.give).toHaveBeenCalledWith('steve', 'minecraft:dirt', 3);
});
it('returns 409 on NotSupportedError', async () => {
const { app } = makeApp({ ban: vi.fn().mockRejectedValue(new NotSupportedError('ban')) });
const res = await request(app).post('/api/players/ban').send({ name: 'x' });
expect(res.status).toBe(409);
expect(res.body).toEqual({ error: 'not_supported' });
});
it('returns 400 for unknown action', async () => {
const { app } = makeApp();
const res = await request(app).post('/api/players/frobnicate').send({ name: 'x' });
expect(res.status).toBe(400);
});
});
+82
View File
@@ -0,0 +1,82 @@
import { describe, it, expect, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
import { createPropertiesRouter } from '../../src/routes/properties.js';
function makeApp() {
const propertiesService = {
read: vi.fn().mockResolvedValue({ difficulty: 'easy', 'max-players': '20' }),
update: vi.fn().mockImplementation(async (p) => ({ difficulty: 'easy', 'max-players': '20', ...p })),
};
const app = express();
app.use(express.json());
app.use('/api/properties', createPropertiesRouter({ propertiesService }));
return { app, propertiesService };
}
describe('properties routes', () => {
it('GET / returns properties', async () => {
const { app } = makeApp();
const res = await request(app).get('/api/properties');
expect(res.body.properties.difficulty).toBe('easy');
});
it('GET / fills defaults for managed keys missing from the file (except motd)', async () => {
const { app } = makeApp();
const res = await request(app).get('/api/properties');
const p = res.body.properties;
// present in the file → kept as-is
expect(p.difficulty).toBe('easy');
expect(p['max-players']).toBe('20');
// absent from the file → default applied
expect(p.pvp).toBe('true');
expect(p['keep-inventory']).toBe('false');
expect(p.hardcore).toBe('false');
expect(p['allow-flight']).toBe('false');
expect(p['force-gamemode']).toBe('false');
expect(p['allow-nether']).toBe('true');
expect(p['enable-command-block']).toBe('false');
expect(p['spawn-monsters']).toBe('true');
expect(p['spawn-animals']).toBe('true');
expect(p['spawn-npcs']).toBe('true');
expect(p.gamemode).toBe('survival');
expect(p['view-distance']).toBe('10');
expect(p['simulation-distance']).toBe('10');
expect(p['spawn-protection']).toBe('16');
// motd deliberately has no default
expect(p.motd).toBeUndefined();
});
it('GET / serves pure defaults when server.properties does not exist yet', async () => {
const propertiesService = {
read: vi.fn().mockRejectedValue(Object.assign(new Error('nope'), { code: 'ENOENT' })),
};
const app = express();
app.use('/api/properties', createPropertiesRouter({ propertiesService }));
const res = await request(app).get('/api/properties');
expect(res.status).toBe(200);
expect(res.body.properties.pvp).toBe('true');
expect(res.body.properties.gamemode).toBe('survival');
});
it('PUT / accepts valid values', async () => {
const { app, propertiesService } = makeApp();
const res = await request(app).put('/api/properties').send({ properties: { difficulty: 'hard' } });
expect(res.status).toBe(200);
expect(propertiesService.update).toHaveBeenCalledWith({ difficulty: 'hard' });
});
it('PUT / rejects invalid enum with 400', async () => {
const { app } = makeApp();
const res = await request(app).put('/api/properties').send({ properties: { difficulty: 'banana' } });
expect(res.status).toBe(400);
expect(res.body.field).toBe('difficulty');
});
it('PUT / rejects non-numeric max-players', async () => {
const { app } = makeApp();
const res = await request(app).put('/api/properties').send({ properties: { 'max-players': 'lots' } });
expect(res.status).toBe(400);
expect(res.body.field).toBe('max-players');
});
});
+54
View File
@@ -0,0 +1,54 @@
import { describe, it, expect, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
import { createStatusRouter } from '../../src/routes/status.js';
import { ContainerNotFoundError } from '../../src/services/dockerService.js';
function makeApp(overrides = {}) {
const dockerService = {
inspect: vi.fn().mockResolvedValue({ state: 'running', type: 'PAPER' }),
stats: vi.fn().mockResolvedValue({ cpuPct: 10, memUsedMb: 512, memPct: 50 }),
start: vi.fn().mockResolvedValue(),
stop: vi.fn().mockResolvedValue(),
restart: vi.fn().mockResolvedValue(),
kill: vi.fn().mockResolvedValue(),
...overrides.dockerService,
};
const adapter = { listPlayers: vi.fn().mockResolvedValue({ online: 1, max: 20, players: ['a'] }) };
const appState = {
getEdition: vi.fn().mockResolvedValue('java'),
getAdapter: vi.fn().mockResolvedValue(adapter),
};
const app = express();
app.use(express.json());
app.use('/api/status', createStatusRouter({ dockerService, appState }));
return { app, dockerService };
}
describe('status routes', () => {
it('GET / returns aggregated status', async () => {
const { app } = makeApp();
const res = await request(app).get('/api/status');
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
state: 'running', edition: 'java', cpuPct: 10, memPct: 50,
players: { online: 1, max: 20, players: ['a'] },
});
});
it('POST /start delegates to dockerService', async () => {
const { app, dockerService } = makeApp();
const res = await request(app).post('/api/status/start');
expect(res.body).toEqual({ ok: true });
expect(dockerService.start).toHaveBeenCalled();
});
it('POST /start returns 404 when container missing', async () => {
const { app } = makeApp({
dockerService: { start: vi.fn().mockRejectedValue(new ContainerNotFoundError('mc')) },
});
const res = await request(app).post('/api/status/start');
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'not_found' });
});
});
+57
View File
@@ -0,0 +1,57 @@
import { describe, it, expect, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
import { PassThrough } from 'node:stream';
import { createWorldRouter } from '../../src/routes/world.js';
function noopUpload() {
// fake multer middleware that sets req.file
return { single: () => (req, _res, next) => { req.file = { path: '/tmp/up.zip' }; next(); } };
}
function makeApp(worldOverrides = {}) {
const worldService = {
regen: vi.fn().mockResolvedValue({ ok: true }),
importWorld: vi.fn().mockResolvedValue({ ok: true }),
createDownloadStream: vi.fn().mockImplementation(() => {
const s = new PassThrough();
process.nextTick(() => { s.end('zipbytes'); });
return s;
}),
...worldOverrides,
};
const app = express();
app.use(express.json());
app.use('/api/world', createWorldRouter({ worldService, upload: noopUpload() }));
return { app, worldService };
}
describe('world routes', () => {
it('POST /regen delegates with seed', async () => {
const { app, worldService } = makeApp();
const res = await request(app).post('/api/world/regen').send({ seed: '99988' });
expect(res.body).toEqual({ ok: true });
expect(worldService.regen).toHaveBeenCalledWith('99988');
});
it('GET /download streams a zip', async () => {
const { app } = makeApp();
const res = await request(app).get('/api/world/download');
expect(res.headers['content-type']).toContain('application/zip');
expect(res.text).toContain('zipbytes');
});
it('POST /upload imports the world', async () => {
const { app, worldService } = makeApp();
const res = await request(app).post('/api/world/upload');
expect(res.body).toEqual({ ok: true });
expect(worldService.importWorld).toHaveBeenCalledWith('/tmp/up.zip');
});
it('POST /upload returns 400 on bad zip', async () => {
const { app } = makeApp({ importWorld: vi.fn().mockRejectedValue(new Error('bad zip')) });
const res = await request(app).post('/api/world/upload');
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'invalid_world' });
});
});
+40
View File
@@ -0,0 +1,40 @@
import { describe, it, expect, vi } from 'vitest';
import request from 'supertest';
import bcrypt from 'bcryptjs';
import { createApp } from '../src/server.js';
function fakeDeps() {
const config = {
adminPasswordHash: bcrypt.hashSync('pw', 10),
sessionSecret: 'test-secret',
nodeEnv: 'test',
mcDataPath: '/data',
};
const dockerService = {
inspect: vi.fn().mockResolvedValue({ state: 'exited', type: 'PAPER' }),
stats: vi.fn(), start: vi.fn(), stop: vi.fn(), restart: vi.fn(), kill: vi.fn(),
};
const appState = { getEdition: vi.fn().mockResolvedValue('java'), getAdapter: vi.fn() };
const propertiesService = { read: vi.fn().mockResolvedValue({}), update: vi.fn() };
const worldService = {};
const authService = { verifyPassword: async (p) => p === 'pw' };
const upload = { single: () => (req, _res, next) => next() };
return { config, dockerService, appState, propertiesService, worldService, authService, upload };
}
describe('server auth guard', () => {
it('blocks /api/status when unauthenticated', async () => {
const { app } = createApp(fakeDeps());
const res = await request(app).get('/api/status');
expect(res.status).toBe(401);
});
it('allows /api/status after login', async () => {
const { app } = createApp(fakeDeps());
const agent = request.agent(app);
await agent.post('/api/auth/login').send({ password: 'pw' });
const res = await agent.get('/api/status');
expect(res.status).toBe(200);
expect(res.body.state).toBe('exited');
});
});
+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).
});
+22
View File
@@ -0,0 +1,22 @@
import { describe, it, expect, vi } from 'vitest';
import { registerSockets } from '../../src/sockets/index.js';
describe('registerSockets', () => {
it('creates /logs and /status namespaces with auth middleware', () => {
const namespaces = {};
const io = {
of: vi.fn((name) => {
const ns = { use: vi.fn(), on: vi.fn() };
namespaces[name] = ns;
return ns;
}),
};
registerSockets(io, {
dockerService: {}, appState: {}, sessionMiddleware: (req, res, next) => next(),
});
expect(io.of).toHaveBeenCalledWith('/logs');
expect(io.of).toHaveBeenCalledWith('/status');
expect(namespaces['/logs'].use).toHaveBeenCalled();
expect(namespaces['/status'].use).toHaveBeenCalled();
});
});
+56
View File
@@ -0,0 +1,56 @@
import { describe, it, expect, vi } from 'vitest';
import { EventEmitter } from 'node:events';
import { PassThrough } from 'node:stream';
import { registerLogsSocket } from '../../src/sockets/logsSocket.js';
function fakeNamespace() {
const ns = new EventEmitter();
ns.on = ns.on.bind(ns);
return ns;
}
function fakeSocket() {
const s = new EventEmitter();
s.emit = s.emit.bind(s);
s.emitted = [];
const origEmit = s.emit;
s.emit = (ev, payload) => { s.emitted.push([ev, payload]); return origEmit(ev, payload); };
return s;
}
describe('logsSocket', () => {
it('streams log chunks to the client', async () => {
const logStream = new PassThrough();
const dockerService = { logStream: vi.fn().mockResolvedValue(logStream) };
const appState = { getAdapter: vi.fn().mockResolvedValue({ sendCommand: vi.fn() }) };
const ns = fakeNamespace();
registerLogsSocket(ns, { dockerService, appState });
const socket = fakeSocket();
ns.emit('connection', socket);
await new Promise((r) => setTimeout(r, 10)); // allow async logStream()
logStream.write('hello world');
await new Promise((r) => setTimeout(r, 5));
const logs = socket.emitted.filter(([ev]) => ev === 'log').map(([, p]) => p);
expect(logs.join('')).toContain('hello world');
});
it('routes command events to adapter.sendCommand', async () => {
const logStream = new PassThrough();
const sendCommand = vi.fn().mockResolvedValue('command result');
const dockerService = { logStream: vi.fn().mockResolvedValue(logStream) };
const appState = { getAdapter: vi.fn().mockResolvedValue({ sendCommand }) };
const ns = fakeNamespace();
registerLogsSocket(ns, { dockerService, appState });
const socket = fakeSocket();
ns.emit('connection', socket);
await new Promise((r) => setTimeout(r, 10));
socket.emit('command', 'list');
await new Promise((r) => setTimeout(r, 5));
expect(sendCommand).toHaveBeenCalledWith('list');
});
});
+33
View File
@@ -0,0 +1,33 @@
import { describe, it, expect, vi } from 'vitest';
import { buildStatusPayload } from '../../src/sockets/statusSocket.js';
describe('buildStatusPayload', () => {
it('aggregates state, stats and players when running', async () => {
const dockerService = {
inspect: vi.fn().mockResolvedValue({ state: 'running', type: 'PAPER' }),
stats: vi.fn().mockResolvedValue({ cpuPct: 5, memUsedMb: 256, memPct: 25 }),
};
const appState = {
getAdapter: vi.fn().mockResolvedValue({
listPlayers: vi.fn().mockResolvedValue({ online: 2, max: 20, players: ['a', 'b'] }),
}),
};
const payload = await buildStatusPayload({ dockerService, appState });
expect(payload).toMatchObject({
state: 'running', cpuPct: 5, memPct: 25,
players: { online: 2, max: 20, players: ['a', 'b'] },
});
});
it('returns zeros and empty players when not running', async () => {
const dockerService = {
inspect: vi.fn().mockResolvedValue({ state: 'exited', type: 'PAPER' }),
stats: vi.fn(),
};
const appState = { getAdapter: vi.fn() };
const payload = await buildStatusPayload({ dockerService, appState });
expect(payload.state).toBe('exited');
expect(payload.players).toEqual({ online: 0, max: 0, players: [] });
expect(dockerService.stats).not.toHaveBeenCalled();
});
});