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:
commit
640e52c0c9
99 files changed
+12115
No files matched your search
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user