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

+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' });
});
});