From 1897b8658673fead0ace17e73adb989e872bb5bd Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Mon, 28 Sep 2026 21:43:53 +0100 Subject: [PATCH 01/13] Add unit tests and run them with npm test The pure logic that decides what a server gets written and which Java runs it had no tests of its own, only the end-to-end checks against a built image. `npm test` now runs node:test over test/unit, with no new dependency and no install step, and CI runs it alongside the image build: - server.properties: parsing (escapes, spacing, CRLF), a new file's defaults and first-start settings per version, merging into a modpack's file line for line, in-place updates, and the Properties page's defaults agreeing with a new server's; - Minecraft version ids: the id pattern, isAtLeast across releases, snapshots and pre-releases, build ordering and world type spellings; - the Java major each version needs, including snapshots and NeoForge's pseudo 1.26 ids; - NeoForge's build to Minecraft version mapping, through listVersions and getBuilds with Maven's list stubbed, and the pre-1.2.1 relabel; - path containment (traversal, look-alike siblings, links out) and the zip magic-number check. The glob is spelt out because a bare `node --test` would also pick up the test/ci scripts, which need a running panel. --- .github/workflows/ci.yml | 17 +++ package.json | 2 +- test/unit/fileSafety.test.js | 61 +++++++++ test/unit/javaVersion.test.js | 51 ++++++++ test/unit/neoforge.test.js | 76 +++++++++++ test/unit/serverProperties.test.js | 199 +++++++++++++++++++++++++++++ test/unit/versions.test.js | 102 +++++++++++++++ 7 files changed, 507 insertions(+), 1 deletion(-) create mode 100644 test/unit/fileSafety.test.js create mode 100644 test/unit/javaVersion.test.js create mode 100644 test/unit/neoforge.test.js create mode 100644 test/unit/serverProperties.test.js create mode 100644 test/unit/versions.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f181df4..f096a14 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,23 @@ permissions: contents: read jobs: + # Pure logic, run straight from the checkout: no image, no npm install + unit: + name: Unit tests + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Run unit tests + run: npm test + # Build the real image once; every check below runs against it, so CI tests # exactly what gets published (the Java runtimes included). build: diff --git a/package.json b/package.json index 2930719..5d79fa8 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "url": "https://www.buymeacoffee.com/willtda" }, "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", + "test": "node --test test/unit/*.test.js", "start": "node src/server.js", "dev": "node --watch src/server.js" }, diff --git a/test/unit/fileSafety.test.js b/test/unit/fileSafety.test.js new file mode 100644 index 0000000..818aa8f --- /dev/null +++ b/test/unit/fileSafety.test.js @@ -0,0 +1,61 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { isPathInside } = require('../../src/utils/pathSafety'); +const { isZipFile } = require('../../src/utils/uploadSafety'); + +function tempDir(t) { + const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'craftbox-safety-'))); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + return dir; +} + +test('isPathInside allows the directory and anything beneath it', (t) => { + const base = tempDir(t); + fs.mkdirSync(path.join(base, 'world')); + assert.equal(isPathInside(base, base), true); + assert.equal(isPathInside(base, path.join(base, 'world')), true); + assert.equal(isPathInside(base, path.join(base, 'world', 'not-yet', 'level.dat')), true, 'a file about to be written'); +}); + +test('isPathInside refuses traversal and look-alike siblings', (t) => { + const base = tempDir(t); + const server = path.join(base, 'server'); + fs.mkdirSync(server); + fs.mkdirSync(path.join(base, 'server-evil')); + assert.equal(isPathInside(server, path.join(server, '..')), false); + assert.equal(isPathInside(server, path.join(server, '..', 'craftbox.sqlite')), false); + assert.equal(isPathInside(server, path.join(base, 'server-evil', 'x')), false, 'prefix match is not containment'); + assert.equal(isPathInside(path.join(base, 'missing'), path.join(base, 'missing', 'x')), false, 'no base directory'); +}); + +test('isPathInside follows symlinks out of the directory', (t) => { + const base = tempDir(t); + const server = path.join(base, 'server'); + const outside = path.join(base, 'outside'); + fs.mkdirSync(server); + fs.mkdirSync(outside); + try { + fs.symlinkSync(outside, path.join(server, 'link'), 'junction'); + } catch (err) { + return t.skip(`cannot create a link here: ${err.code}`); + } + assert.equal(isPathInside(server, path.join(server, 'link', 'secret.txt')), false); +}); + +test('isZipFile checks the magic number, not the name', (t) => { + const dir = tempDir(t); + const file = (name, bytes) => { + const p = path.join(dir, name); + fs.writeFileSync(p, Buffer.from(bytes)); + return p; + }; + assert.equal(isZipFile(file('a.jar', [0x50, 0x4b, 0x03, 0x04, 0, 0])), true, 'local file header'); + assert.equal(isZipFile(file('empty.zip', [0x50, 0x4b, 0x05, 0x06, 0, 0])), true, 'empty archive'); + assert.equal(isZipFile(file('spanned.zip', [0x50, 0x4b, 0x07, 0x08])), true, 'spanned archive'); + assert.equal(isZipFile(file('fake.jar', [0x7f, 0x45, 0x4c, 0x46])), false, 'an ELF binary named .jar'); + assert.equal(isZipFile(file('short.jar', [0x50, 0x4b])), false, 'too short'); + assert.equal(isZipFile(path.join(dir, 'missing.jar')), false); +}); diff --git a/test/unit/javaVersion.test.js b/test/unit/javaVersion.test.js new file mode 100644 index 0000000..5095c4d --- /dev/null +++ b/test/unit/javaVersion.test.js @@ -0,0 +1,51 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const { getRequiredJavaVersion, getJavaForMajor, TEMURIN_PATHS } = require('../../src/utils/javaVersion'); + +test('releases map to the Java they need', () => { + const cases = { + '1.7.10': 8, '1.12.2': 8, '1.16.5': 8, + '1.17': 17, '1.17.1': 17, '1.18.2': 17, '1.20.4': 17, + '1.20.5': 21, '1.20.6': 21, '1.21': 21, '1.21.1': 21, '1.21.11': 21, + '26.1': 25, '26.1.2': 25, '27.1': 25 + }; + for (const [version, java] of Object.entries(cases)) { + assert.equal(getRequiredJavaVersion(version), java, version); + } +}); + +test('pre-releases and release candidates need their release\'s Java', () => { + assert.equal(getRequiredJavaVersion('1.20.5-pre1'), 21); + assert.equal(getRequiredJavaVersion('1.20.5-rc1'), 21); + assert.equal(getRequiredJavaVersion('1.17-pre1'), 17); + assert.equal(getRequiredJavaVersion('1.14 Pre-Release 5'), 8); +}); + +test('snapshots map by year', () => { + assert.equal(getRequiredJavaVersion('20w45a'), 8); + assert.equal(getRequiredJavaVersion('21w37a'), 17); + assert.equal(getRequiredJavaVersion('23w51b'), 17); + assert.equal(getRequiredJavaVersion('24w14a'), 21); + assert.equal(getRequiredJavaVersion('25w03a'), 21); + assert.equal(getRequiredJavaVersion('26w14a'), 25); +}); + +test('NeoForge\'s pseudo 1.26+ ids are the year-based era', () => { + assert.equal(getRequiredJavaVersion('1.26.1'), 25); +}); + +test('no version (a custom jar) gets the newest Java', () => { + for (const v of [undefined, null, '', 26]) assert.equal(getRequiredJavaVersion(v), 25, String(v)); +}); + +test('the image\'s JREs are all known', () => { + assert.deepEqual(Object.keys(TEMURIN_PATHS).map(Number), [8, 17, 21, 25]); + for (const p of Object.values(TEMURIN_PATHS)) assert.match(p, /^\/usr\/lib\/jvm\/temurin-\d+-jre-[a-z0-9]+\/bin\/java$/); +}); + +const hasTemurin = process.platform !== 'win32' && Object.values(TEMURIN_PATHS).some((p) => fs.existsSync(p)); + +test('outside the Docker image, Java comes from PATH', { skip: hasTemurin && 'running where the Temurin JREs are installed' }, () => { + for (const major of [8, 17, 21, 25]) assert.equal(getJavaForMajor(major), 'java'); +}); diff --git a/test/unit/neoforge.test.js b/test/unit/neoforge.test.js new file mode 100644 index 0000000..a593756 --- /dev/null +++ b/test/unit/neoforge.test.js @@ -0,0 +1,76 @@ +// NeoForge names its builds after the Minecraft version they target, and +// that naming changed with Minecraft's year-based versions. The mapping is +// private to the module, so these drive it through listVersions/getBuilds +// with Maven's version list stubbed. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const neoforge = require('../../src/mc/serverTypes/neoforge'); +const { relabelLegacyVersion } = neoforge; + +const MAVEN_VERSIONS = [ + '20.2.3-beta', + '20.4.237', + '20.6.119', + '21.0.167', + '21.1.1', '21.1.95', '21.1.100', + '21.9.16-beta', '21.9.16', + '0.25w14craftmine.3-beta', + '26.1.0.7', + '26.1.1.3', + '26.1.2.112', + '26.2.0.88-beta' +]; + +test.beforeEach((t) => { + t.mock.method(globalThis, 'fetch', async (url) => { + assert.match(String(url), /maven\.neoforged\.net\/api\/maven\/versions\/releases\/net\/neoforged\/neoforge$/); + return { ok: true, status: 200, json: async () => ({ versions: MAVEN_VERSIONS }) }; + }); +}); + +test('builds are listed under the Minecraft version they target', async () => { + const { versions, latest } = await neoforge.listVersions(); + assert.deepEqual(versions.map((v) => v.id), ['26.1.2', '26.1.1', '26.1', '1.21.9', '1.21.1', '1.21', '1.20.6', '1.20.4']); + assert.ok(versions.every((v) => v.channel === 'stable')); + assert.equal(latest, '26.1.2'); +}); + +test('versions with only beta builds appear on the "all" channel', async () => { + const { versions, latest } = await neoforge.listVersions({ channel: 'all' }); + const channel = Object.fromEntries(versions.map((v) => [v.id, v.channel])); + assert.equal(channel['26.2'], 'beta'); + assert.equal(channel['1.20.2'], 'beta'); + assert.equal(channel['1.21.9'], 'stable', 'a version with a stable build is stable'); + assert.equal(versions[0].id, '26.2'); + assert.equal(latest, '26.1.2', 'latest is never a beta'); + assert.ok(!versions.some((v) => /craftmine|^0\./.test(v.id)), 'April Fools builds excluded'); +}); + +test('getBuilds lists one version\'s builds, newest first', async () => { + assert.deepEqual((await neoforge.getBuilds('1.21.1')).map((b) => b.build), ['21.1.100', '21.1.95', '21.1.1']); + assert.deepEqual(await neoforge.getBuilds('1.21.9'), [ + { build: '21.9.16', channel: 'release' }, + { build: '21.9.16-beta', channel: 'beta' } + ]); + assert.deepEqual((await neoforge.getBuilds('26.1')).map((b) => b.build), ['26.1.0.7']); + assert.deepEqual((await neoforge.getBuilds('26.1.2')).map((b) => b.build), ['26.1.2.112']); + assert.deepEqual((await neoforge.getBuilds('1.21')).map((b) => b.build), ['21.0.167']); +}); + +test('getBuilds accepts a pre-1.2.1 "1.26.x" label', async () => { + assert.deepEqual((await neoforge.getBuilds('1.26.1')).map((b) => b.build), ['26.1.0.7']); +}); + +test('relabelLegacyVersion reads the real version off the build', () => { + assert.equal(relabelLegacyVersion('1.26.1', '26.1.2.112'), '26.1.2'); + assert.equal(relabelLegacyVersion('1.26.1', '26.1.0.7'), '26.1'); + assert.equal(relabelLegacyVersion('1.26.1'), '26.1'); + assert.equal(relabelLegacyVersion('1.26.2', '21.1.5'), '26.2', 'a build in another form is ignored'); +}); + +test('relabelLegacyVersion leaves real versions alone', () => { + assert.equal(relabelLegacyVersion('1.21.1', '21.1.95'), '1.21.1'); + assert.equal(relabelLegacyVersion('26.1', '26.1.0.7'), '26.1'); + assert.equal(relabelLegacyVersion('26.1.2', '26.1.2.112'), '26.1.2'); + assert.equal(relabelLegacyVersion(null), null); +}); diff --git a/test/unit/serverProperties.test.js b/test/unit/serverProperties.test.js new file mode 100644 index 0000000..aab08d8 --- /dev/null +++ b/test/unit/serverProperties.test.js @@ -0,0 +1,199 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { + writeServerProperties, writeEula, parseServerProperties, updateServerProperties, DEFAULT_PROPERTIES +} = require('../../src/mc/serverProperties'); +const { PROPERTY_META } = require('../../src/mc/propertyMeta'); + +function tempDir(t) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'craftbox-props-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + return dir; +} + +const read = (dir) => fs.readFileSync(path.join(dir, 'server.properties'), 'utf8'); +const write = (dir, content) => fs.writeFileSync(path.join(dir, 'server.properties'), content); + +// The keys of a written file, in order, ignoring comments +const keysOf = (content) => content.split('\n') + .filter((l) => l && !l.startsWith('#')) + .map((l) => l.slice(0, l.indexOf('='))); + +test('parse undoes the escapes Minecraft writes and tolerates spacing', (t) => { + const dir = tempDir(t); + write(dir, [ + '#Minecraft server properties', + '#Mon Sep 28 12:00:00 BST 2026', + 'level-type=minecraft\\:flat', + 'motd=A \\= sign', + 'max-players = 7', + 'spawn-protection =16', + 'level-seed=', + '', + 'not a property', + 'generator-settings={"a"\\:1}', + '' + ].join('\r\n')); + assert.deepEqual(parseServerProperties(dir), { + 'level-type': 'minecraft:flat', + 'motd': 'A = sign', + 'max-players': '7', + 'spawn-protection': '16', + 'level-seed': '', + 'generator-settings': '{"a":1}' + }); +}); + +test('parse returns an empty object when there is no file', (t) => { + assert.deepEqual(parseServerProperties(tempDir(t)), {}); +}); + +test('a new file has the defaults, with first-start settings after the seed', (t) => { + const dir = tempDir(t); + writeServerProperties(dir, {}, { version: '1.21.1' }); + const content = read(dir); + assert.match(content, /^#Minecraft server properties\n#Generated by Craftbox on /); + const keys = keysOf(content); + const seed = keys.indexOf('level-seed'); + assert.deepEqual(keys.slice(seed, seed + 6), [ + 'level-seed', 'hardcore', 'level-type', 'generator-settings', 'initial-enabled-packs', 'initial-disabled-packs' + ]); + const props = parseServerProperties(dir); + assert.equal(props.difficulty, 'normal'); + assert.equal(props.gamemode, 'survival'); + assert.equal(props['online-mode'], 'true'); + assert.equal(props['level-type'], 'minecraft:normal'); + assert.equal(props['generator-settings'], '{}'); + for (const key of Object.keys(DEFAULT_PROPERTIES)) assert.ok(key in props, `${key} missing`); +}); + +test('first-start settings follow the version', (t) => { + const cases = { + '1.12.2': { 'level-type': 'default', 'generator-settings': '', packs: false }, + '1.16.5': { 'level-type': 'default', 'generator-settings': '', packs: false }, + '1.18.2': { 'level-type': 'default', 'generator-settings': '{}', packs: false }, + '1.19.2': { 'level-type': 'minecraft:normal', 'generator-settings': '{}', packs: false }, + '1.19.3': { 'level-type': 'minecraft:normal', 'generator-settings': '{}', packs: true }, + '26.1': { 'level-type': 'minecraft:normal', 'generator-settings': '{}', packs: true }, + '': { 'level-type': 'minecraft:normal', 'generator-settings': '{}', packs: true } // a custom jar + }; + for (const [version, want] of Object.entries(cases)) { + const dir = tempDir(t); + writeServerProperties(dir, {}, { version }); + const props = parseServerProperties(dir); + assert.equal(props['level-type'], want['level-type'], `${version} level-type`); + assert.equal(props['generator-settings'], want['generator-settings'], `${version} generator-settings`); + assert.equal(props.hardcore, 'false', `${version} hardcore`); + assert.equal('initial-enabled-packs' in props, want.packs, `${version} initial-enabled-packs`); + } +}); + +test('overrides take camelCase and raw keys', (t) => { + const dir = tempDir(t); + writeServerProperties(dir, { + serverPort: 25570, maxPlayers: 5, motd: 'Hi', gamemode: 'creative', difficulty: 'hard', + viewDistance: 6, levelSeed: '1234', levelType: 'minecraft:flat', + 'enable-rcon': true, 'rcon.password': 'secret', notAProperty: 'x' + }, { version: '1.21.1' }); + const props = parseServerProperties(dir); + assert.equal(props['server-port'], '25570'); + assert.equal(props['max-players'], '5'); + assert.equal(props.motd, 'Hi'); + assert.equal(props.gamemode, 'creative'); + assert.equal(props.difficulty, 'hard'); + assert.equal(props['view-distance'], '6'); + assert.equal(props['level-seed'], '1234'); + assert.equal(props['level-type'], 'minecraft:flat'); + assert.equal(props['enable-rcon'], 'true'); + assert.equal(props['rcon.password'], 'secret'); + assert.ok(!('notAProperty' in props)); +}); + +test('merging keeps an existing file line for line and only fills the gaps', (t) => { + const dir = tempDir(t); + write(dir, [ + '#From a modpack', + 'level-type=minecraft\\:amplified', + 'motd=\\u00A7aHello', + 'allow-flight=true', + 'server-port=1234', + '' + ].join('\n')); + writeServerProperties(dir, { serverPort: 25570 }, { mergeExisting: true, version: '1.21.1' }); + const lines = read(dir).split('\n'); + assert.deepEqual(lines.slice(0, 5), [ + '#From a modpack', + 'level-type=minecraft\\:amplified', + 'motd=\\u00A7aHello', + 'allow-flight=true', + 'server-port=25570' + ]); + assert.equal(lines.at(-1), '', 'trailing newline kept'); + const props = parseServerProperties(dir); + assert.equal(props['online-mode'], 'true', 'missing default filled in'); + assert.equal(props.hardcore, 'false', 'missing first-start setting filled in'); + assert.equal(lines.filter((l) => l.startsWith('level-type=')).length, 1, 'level-type duplicated'); +}); + +test('merging with no existing file writes a fresh one', (t) => { + const dir = tempDir(t); + writeServerProperties(dir, { serverPort: 25571 }, { mergeExisting: true, version: '1.21.1' }); + assert.match(read(dir), /^#Minecraft server properties/); + assert.equal(parseServerProperties(dir)['server-port'], '25571'); +}); + +test('update edits in place, keeps comments and appends new keys', (t) => { + const dir = tempDir(t); + write(dir, '#comment\na=1\nb = 2\n\nc=3\n'); + updateServerProperties(dir, { b: 20, d: 4 }); + assert.equal(read(dir), '#comment\na=1\nb=20\n\nc=3\nd=4\n'); +}); + +test('update keeps a file without a trailing newline that way', (t) => { + const dir = tempDir(t); + write(dir, 'a=1'); + updateServerProperties(dir, { b: 2 }); + assert.equal(read(dir), 'a=1\nb=2'); +}); + +test('update does nothing when there is no file', (t) => { + const dir = tempDir(t); + updateServerProperties(dir, { a: 1 }); + assert.equal(fs.existsSync(path.join(dir, 'server.properties')), false); +}); + +test('writeEula accepts the EULA', (t) => { + const dir = tempDir(t); + writeEula(dir); + const content = fs.readFileSync(path.join(dir, 'eula.txt'), 'utf8'); + assert.match(content, /^eula=true$/m); + assert.match(content, /^#.*aka\.ms\/MinecraftEULA/m); +}); + +// The Properties page shows PROPERTY_META's defaults; a new server gets +// DEFAULT_PROPERTIES. A default changed in one and not the other shows the +// user a "default" the server never had. +test('the Properties page defaults agree with a new server', () => { + const disagree = []; + for (const [key, value] of Object.entries(DEFAULT_PROPERTIES)) { + const meta = PROPERTY_META[key]; + if (meta && String(meta.default) !== String(value)) disagree.push(`${key}: ${value} vs ${meta.default}`); + } + assert.deepEqual(disagree, []); +}); + +test('every Properties page default is valid for its own field', () => { + const invalid = []; + for (const [key, meta] of Object.entries(PROPERTY_META)) { + if (meta.type === 'enum' && !meta.options.some((o) => o.value === meta.default)) invalid.push(key); + if (meta.type === 'boolean' && !['true', 'false'].includes(meta.default)) invalid.push(key); + if (meta.type === 'number') { + const n = Number(meta.default); + if (!Number.isFinite(n) || n < meta.min || n > meta.max) invalid.push(key); + } + } + assert.deepEqual(invalid, []); +}); diff --git a/test/unit/versions.test.js b/test/unit/versions.test.js new file mode 100644 index 0000000..5b745c9 --- /dev/null +++ b/test/unit/versions.test.js @@ -0,0 +1,102 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { MC_VERSION_RE, isReleaseVersion, isAtLeast } = require('../../src/utils/mcVersion'); +const { classifyMcId, pickLatestBuild, compareBuilds } = require('../../src/mc/serverTypes/_channels'); +const { worldTypesFor, worldTypeFor, firstStartProperties } = require('../../src/mc/worldTypes'); + +test('MC_VERSION_RE accepts every id form Mojang uses', () => { + for (const v of ['1.21.4', '1.21', '26.1', '1.21.5-pre1', '1.21.5-rc2', '25w03a', '23w13a_or_b', '1.14 Pre-Release 5', 'b1.7.3']) { + assert.ok(MC_VERSION_RE.test(v), v); + } +}); + +test('MC_VERSION_RE refuses anything path- or URL-breaking', () => { + for (const v of ['', '../1.21', '1.21/evil', '1.21?x=1', '1.21#x', ' 1.21', '-1.21', '.1.21', 'a'.repeat(65), '1.21\n']) { + assert.ok(!MC_VERSION_RE.test(v), JSON.stringify(v)); + } +}); + +test('isReleaseVersion matches plain releases only', () => { + for (const v of ['1.21', '1.21.4', '26.1', '26.1.2']) assert.ok(isReleaseVersion(v), v); + for (const v of ['1.21.5-pre1', '25w03a', '1.14 Pre-Release 5', '', undefined, null, '1']) assert.ok(!isReleaseVersion(v), String(v)); +}); + +test('isAtLeast compares releases numerically', () => { + assert.equal(isAtLeast('1.19.3', '1.19.3', '22w42a'), true); + assert.equal(isAtLeast('1.19.2', '1.19.3', '22w42a'), false); + assert.equal(isAtLeast('1.20', '1.19.3', '22w42a'), true); + assert.equal(isAtLeast('1.19', '1.19.0', '22w11a'), true); + assert.equal(isAtLeast('1.9', '1.19', '22w11a'), false, '1.9 is not after 1.19'); + assert.equal(isAtLeast('1.10', '1.9', '15w31a'), true, '1.10 is after 1.9'); + assert.equal(isAtLeast('26.1', '1.19.3', '22w42a'), true); +}); + +test('isAtLeast places snapshots, pre-releases and unknown forms', () => { + assert.equal(isAtLeast('22w42a', '1.19.3', '22w42a'), true); + assert.equal(isAtLeast('22w45a', '1.19.3', '22w42a'), true); + assert.equal(isAtLeast('22w40a', '1.19.3', '22w42a'), false); + assert.equal(isAtLeast('26w14a', '1.19.3', '22w42a'), true); + assert.equal(isAtLeast('1.19.3-pre1', '1.19.3', '22w42a'), true, 'a pre-release counts as its release'); + assert.equal(isAtLeast('1.19.3-rc2', '1.19.3', '22w42a'), true); + assert.equal(isAtLeast('1.19.2-pre1', '1.19.3', '22w42a'), false); + assert.equal(isAtLeast('1.14 Pre-Release 5', '1.14', '18w43a'), true); + assert.equal(isAtLeast('', '1.19.3', '22w42a'), true, 'a custom jar counts as current'); + assert.equal(isAtLeast('something-odd', '1.19.3', '22w42a'), true); +}); + +test('classifyMcId names the channel of a non-release id', () => { + assert.equal(classifyMcId('1.21.5-pre1'), 'pre-release'); + assert.equal(classifyMcId('1.14 Pre-Release 5'), 'pre-release'); + assert.equal(classifyMcId('1.21.5-rc2'), 'rc'); + assert.equal(classifyMcId('25w03a'), 'snapshot'); +}); + +test('pickLatestBuild takes the head of a newest-first list', () => { + assert.equal(pickLatestBuild([]), null); + assert.equal(pickLatestBuild(null), null); + assert.deepEqual(pickLatestBuild([{ build: 3 }, { build: 2 }]), { build: 3 }); +}); + +test('compareBuilds orders numbers and dotted versions', () => { + const newer = (a, b) => assert.ok(compareBuilds(a, b) > 0 && compareBuilds(b, a) < 0, `${a} > ${b}`); + newer(100, 99); + newer('100', '99'); + newer('21.1.100', '21.1.95'); + newer('0.16.10', '0.16.9'); + newer('47.4.23', '47.4.10'); + newer('21.9.16', '21.9.16-beta'); + newer('21.1.1', '21.1'); + newer('26.1.2.112', '26.1.1.3'); + assert.equal(compareBuilds('21.1.95', '21.1.95'), 0); + assert.equal(compareBuilds(null, 5), 0); + assert.equal(compareBuilds(5, undefined), 0); +}); + +test('world types use presets from 1.19 and legacy names before', () => { + assert.equal(worldTypesFor('1.18.2')[0].value, 'default'); + assert.equal(worldTypesFor('22w10a')[0].value, 'default'); + assert.equal(worldTypesFor('1.19')[0].value, 'minecraft:normal'); + assert.equal(worldTypesFor('22w11a')[0].value, 'minecraft:normal'); + assert.equal(worldTypesFor('26.1')[0].value, 'minecraft:normal'); +}); + +test('worldTypeFor translates a type into the spelling a version reads', () => { + assert.equal(worldTypeFor('flat', '1.21.1'), 'minecraft:flat'); + assert.equal(worldTypeFor('minecraft:flat', '1.18.2'), 'flat'); + assert.equal(worldTypeFor('default', '1.21.1'), 'minecraft:normal'); + assert.equal(worldTypeFor('LARGEBIOMES', '1.16.5'), 'largeBiomes'); + assert.equal(worldTypeFor('DEFAULT', '1.12.2'), 'default'); + assert.equal(worldTypeFor('minecraft:single_biome_surface', '1.16.5'), null, 'no Single Biome before 1.19'); + assert.equal(worldTypeFor('bogus', '1.21.1'), null); + assert.equal(worldTypeFor('', '1.21.1'), null); +}); + +test('firstStartProperties matches what each version writes itself', () => { + assert.deepEqual(firstStartProperties('1.16.5'), { hardcore: false, 'level-type': 'default', 'generator-settings': '' }); + assert.deepEqual(firstStartProperties('1.18.2'), { hardcore: false, 'level-type': 'default', 'generator-settings': '{}' }); + assert.deepEqual(firstStartProperties('1.19.2'), { hardcore: false, 'level-type': 'minecraft:normal', 'generator-settings': '{}' }); + assert.deepEqual(firstStartProperties('1.19.3'), { + hardcore: false, 'level-type': 'minecraft:normal', 'generator-settings': '{}', + 'initial-enabled-packs': 'vanilla', 'initial-disabled-packs': '' + }); +}); From c314875c3b6a59a5d0bdf1ec6c15797a16c895de Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Mon, 28 Sep 2026 21:44:05 +0100 Subject: [PATCH 02/13] Leave tests, docs and workflows out of the Docker build context The image only copies src/, views/, public/ and the package files, so none of these ever reached it, but they were still sent to the builder on every build. Ignoring them keeps the context to what the image uses, and a later broad COPY can't pull the tests or the README screenshots in by accident. --- .dockerignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.dockerignore b/.dockerignore index fde8ef5..3f800b0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,4 +6,7 @@ docker-compose.yml jsconfig.json .git .gitignore -data \ No newline at end of file +data +test +docs +.github From 184134c76c16f4259f01d235f19c4517eda1e9de Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Mon, 28 Sep 2026 21:50:58 +0100 Subject: [PATCH 03/13] ci: Retry through upstream outages and label them as such Every check that creates a server depends on Mojang, PaperMC, the Forge and NeoForge Mavens, Fabric or Purpur, with no retries, so a brief outage anywhere failed the run and looked exactly like a Craftbox regression. lib.mjs now has: - upstreamFetch(), for the tests' own calls out: network errors, timeouts, 429 and 5xx are retried with backoff; - withUpstream(), for anything that has Craftbox call out: a failure that reads like the network's (Craftbox's own "HTTP 503", "fetch failed" or "timed out", or a Forge installer's Java socket exceptions) is retried once after a pause. If it persists, the runner probes the upstreams involved itself. Only when the runner can't reach one either is it reported as an outage; otherwise it stays a Craftbox failure, noted as such; - provisionServer(), which creates a server and waits it out through withUpstream, deleting a failed attempt's server before the next. The API smoke and server start tests use them. An outage still fails the job, so nothing merges on a check that never ran, but the log, the annotation and a section of its own in the step summary all say "Upstream unavailable", naming the service. Annotation titles are now escaped as workflow commands require: a check name with a comma, such as "creates, writes, reads...", used to cut the title short, and multi-line errors keep their line breaks. --- test/ci/api-smoke.mjs | 39 +++++---- test/ci/lib.mjs | 176 +++++++++++++++++++++++++++++++++++++-- test/ci/server-start.mjs | 36 ++++---- 3 files changed, 212 insertions(+), 39 deletions(-) diff --git a/test/ci/api-smoke.mjs b/test/ci/api-smoke.mjs index 2219f78..2f17a25 100644 --- a/test/ci/api-smoke.mjs +++ b/test/ci/api-smoke.mjs @@ -7,7 +7,8 @@ // CRAFTBOX_URL=http://localhost:6464 node test/ci/api-smoke.mjs import { - waitForPanel, bootstrapPanel, apiClient, waitForState, + waitForPanel, bootstrapPanel, apiClient, waitForState, provisionServer, + withUpstream, upstreamFetch, transientError, createRunner, assert, assertStatus, sleep, makeZip } from './lib.mjs'; @@ -66,7 +67,11 @@ await run.step('lists every server type', async () => { assert(missing.length === 0, `missing types: ${missing.join(', ')}`); }); await run.step('lists vanilla versions with a latest stable', async () => { - const res = await api('GET', '/versions?type=vanilla'); + const res = await withUpstream(['mojang'], async () => { + const r = await api('GET', '/versions?type=vanilla'); + if (r.status === 500) throw transientError(`versions: HTTP 500 ${r.text}`); + return r; + }); assertStatus(res, 200, 'versions'); latest = res.body.latest; assert(latest, 'no latest version'); @@ -97,11 +102,13 @@ await run.step('validates create input', async () => { } }); await run.step('creates a vanilla server from version "latest"', async () => { - const res = await api('POST', '/servers', { ...validBody(), version: 'latest' }); - assertStatus(res, 201, 'create'); - assert(res.body.server.state === 'provisioning', `state was ${res.body.server.state}`); - assert(res.body.server.version === latest, `"latest" recorded as ${res.body.server.version}, expected ${latest}`); - const server = await waitForState(api, res.body.server.id, ['stopped'], { timeoutMs: PROVISION_TIMEOUT, label: 'vanilla' }); + const server = await provisionServer(api, { ...validBody(), version: 'latest' }, { + timeoutMs: PROVISION_TIMEOUT, label: 'vanilla', + onCreated: (res) => { + assert(res.body.server.state === 'provisioning', `state was ${res.body.server.state}`); + assert(res.body.server.version === latest, `"latest" recorded as ${res.body.server.version}, expected ${latest}`); + } + }); assert(server.version === latest, `version ${server.version}, expected ${latest}`); id = server.id; // the rest only runs against a fully provisioned server }); @@ -293,7 +300,7 @@ await run.step('installs a .mrpack without letting it touch the launch files', a assert(mc, 'no stable Fabric version'); // Craftbox picks Fabric's loader itself (no builds endpoint), but a pack // pins one, so ask Fabric's meta API, which Craftbox installs from - const loaders = await (await fetch('https://meta.fabricmc.net/v2/versions/loader')).json(); + const loaders = await (await upstreamFetch('https://meta.fabricmc.net/v2/versions/loader')).json(); const loader = (loaders.find((l) => l.stable) || loaders[0])?.version; assert(loader, 'no Fabric loader version'); @@ -310,13 +317,15 @@ await run.step('installs a .mrpack without letting it touch the launch files', a 'server-overrides/evil.jar': 'PK', 'server-overrides/server.properties': 'level-type=minecraft\\:amplified\nallow-flight=true\nonline-mode=false\n' }); - const form = new FormData(); - for (const [k, v] of Object.entries({ name: 'CI Pack', port: '25570', memory: '2048', eula: 'true', levelType: '' })) form.append(k, v); - form.append('mrpack', new Blob([pack]), 'ci.mrpack'); - const res = await api('POST', '/servers/from-mrpack', form); - assertStatus(res, 201, 'from-mrpack'); - const packId = res.body.server.id; - await waitForState(api, packId, ['stopped'], { timeoutMs: PROVISION_TIMEOUT, label: 'mrpack' }); + const form = () => { + const f = new FormData(); + for (const [k, v] of Object.entries({ name: 'CI Pack', port: '25570', memory: '2048', eula: 'true', levelType: '' })) f.append(k, v); + f.append('mrpack', new Blob([pack]), 'ci.mrpack'); + return f; + }; + const packId = (await provisionServer(api, form, { + path: '/servers/from-mrpack', timeoutMs: PROVISION_TIMEOUT, label: 'mrpack', upstreams: ['fabric', 'mojang'] + })).id; const file = (path) => api('GET', `/servers/${packId}/file?path=${encodeURIComponent(path)}`); assert((await file('config/ci.txt')).body?.file?.content === 'kept', 'ordinary override missing'); diff --git a/test/ci/lib.mjs b/test/ci/lib.mjs index efe1f9e..93ce399 100644 --- a/test/ci/lib.mjs +++ b/test/ci/lib.mjs @@ -10,6 +10,124 @@ const API = `${BASE_URL}/api/v1`; export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +// ── Upstream services ── +// +// Creating a server makes Craftbox download from Mojang, PaperMC, the Forge +// and NeoForge Mavens, Fabric or Purpur, and some checks call Modrinth. Any of +// them can be down or slow for a while, which is no fault of Craftbox's, so +// such failures are retried and, if they persist, reported as an upstream +// outage (UpstreamError) rather than as a failed check. + +export const UPSTREAMS = { + mojang: 'https://launchermeta.mojang.com/mc/game/version_manifest.json', + paper: 'https://fill.papermc.io/v3/projects/paper', + folia: 'https://fill.papermc.io/v3/projects/folia', + purpur: 'https://api.purpurmc.org/v2/purpur', + fabric: 'https://meta.fabricmc.net/v2/versions/game', + forge: 'https://files.minecraftforge.net/net/minecraftforge/forge/promotions_slim.json', + forgeMaven: 'https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml', + neoforge: 'https://maven.neoforged.net/api/maven/versions/releases/net/neoforged/neoforge', + modrinth: 'https://api.modrinth.com/v2/' +}; + +// What each server type pulls from when it's created. Every type but custom +// also asks Mojang which Java its version needs. +export const TYPE_UPSTREAMS = { + vanilla: ['mojang'], + paper: ['paper', 'mojang'], + folia: ['folia', 'mojang'], + purpur: ['purpur', 'mojang'], + fabric: ['fabric', 'mojang'], + forge: ['forge', 'forgeMaven', 'mojang'], + neoforge: ['neoforge', 'mojang'], + custom: ['mojang'] // the tests point custom servers at Mojang's own jars +}; + +export class UpstreamError extends Error { + constructor(service, detail) { + super(`${service} unavailable: ${detail}`); + this.name = 'UpstreamError'; + this.service = service; + } +} + +// Messages that point at the network or an upstream rather than Craftbox: +// Craftbox's own download errors (`HTTP 503`, `fetch failed`, `timed out`), +// Node's socket errors, and the Java exceptions a Forge/NeoForge installer +// prints when it can't fetch a library. +const TRANSIENT_RE = /\bHTTP (?:5\d\d|429)\b|response code: (?:5\d\d|429)|fetch failed|timed out|\btimeout\b|ECONNRESET|ECONNREFUSED|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|ENETUNREACH|socket hang up|UND_ERR|SocketTimeoutException|ConnectException|UnknownHostException|SSLHandshakeException|Connection reset/i; + +export function looksTransient(err) { + return err?.transient === true || TRANSIENT_RE.test(String(err?.message || err || '')); +} + +// An error the check itself raises for something that may be an outage, such +// as Craftbox answering 500 when it couldn't list an upstream's versions. +export function transientError(message) { + return Object.assign(new Error(message), { transient: true }); +} + +// fetch() for a service outside Craftbox. Network errors, timeouts, 429 and +// 5xx are retried with backoff and then thrown as an UpstreamError; any other +// response is returned for the caller to judge. +export async function upstreamFetch(url, init = {}, { attempts = 4, timeoutMs = 30_000 } = {}) { + const host = new URL(url).host; + let detail = ''; + for (let attempt = 1; attempt <= attempts; attempt++) { + if (attempt > 1) await sleep(2000 * 2 ** (attempt - 2)); // 2s, 4s, 8s + try { + const res = await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) }); + if (res.status !== 429 && res.status < 500) return res; + detail = `HTTP ${res.status}`; + await res.body?.cancel(); + } catch (err) { + detail = err.cause?.code || err.message; + } + } + throw new UpstreamError(host, `${detail} (${attempts} attempts)`); +} + +// The first of the named upstreams the runner can't reach, as an +// UpstreamError, or null when they all answer. +export async function probeUpstreams(names) { + for (const name of names) { + try { + await (await upstreamFetch(UPSTREAMS[name], {}, { attempts: 3 })).body?.cancel(); + } catch (err) { + if (err instanceof UpstreamError) return err; + throw err; + } + } + return null; +} + +// Run `fn`, which has Craftbox call out to the `names` upstreams. A failure +// that looks like the network's is retried after a pause. If it persists and +// the runner can't reach one of those upstreams either, it's rethrown as an +// UpstreamError; if they all answer, the failure is Craftbox's to explain. +export async function withUpstream(names, fn, { attempts = 2, pauseMs = 20_000 } = {}) { + let lastErr; + for (let attempt = 1; attempt <= attempts; attempt++) { + try { + return await fn(attempt); + } catch (err) { + if (err instanceof UpstreamError || !looksTransient(err)) throw err; + lastErr = err; + if (attempt < attempts) { + console.log(` retrying in ${pauseMs / 1000}s: ${err.message.split('\n')[0]}`); + await sleep(pauseMs); + } + } + } + const down = await probeUpstreams(names); + if (down) { + down.message += `\n${lastErr.message}`; + throw down; + } + lastErr.message += `\n(${names.join(', ')} answered the runner, so this is not an outage there)`; + throw lastErr; +} + // ── Panel bootstrap ── // Wait until the panel answers at all (a container takes a few seconds to boot). @@ -153,6 +271,27 @@ export async function waitForState(api, id, states, { timeoutMs, label = id } = } } +// Create a server (POST `path` with `body`, JSON or FormData) and wait for it +// to finish provisioning, retrying through a short upstream outage. A failed +// attempt's server is deleted before the next. `onCreated` sees the 201 +// response of each attempt. Resolves to the provisioned server. +export async function provisionServer(api, body, { + path = '/servers', timeoutMs, label, upstreams = TYPE_UPSTREAMS[body.serverType] || ['mojang'], onCreated +} = {}) { + return withUpstream(upstreams, async () => { + const res = await api('POST', path, typeof body === 'function' ? body() : body); + assertStatus(res, 201, `create ${label || ''}`.trim()); + await onCreated?.(res); + const id = res.body.server.id; + try { + return await waitForState(api, id, ['stopped'], { timeoutMs, label: label || id }); + } catch (err) { + await api('DELETE', `/servers/${id}`).catch(() => {}); + throw err; + } + }); +} + // ── Test fixtures ── // A zip of `entries` ({name: string | Buffer}), stored uncompressed: enough for @@ -197,6 +336,14 @@ export function makeZip(entries) { // ── Tiny test runner ── +// Workflow-command escaping: the message may span lines, and a property +// (the title) may hold none of `:` or `,` unescaped. +const escapeData = (s) => String(s).replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A'); +const escapeProperty = (s) => escapeData(s).replace(/:/g, '%3A').replace(/,/g, '%2C'); + +// Checks that couldn't run because an upstream was down fail the run as +// well, but are labelled as such everywhere: the log, the annotation and a +// section of their own in the step summary. export function createRunner(title) { const results = []; @@ -208,10 +355,13 @@ export function createRunner(title) { results.push({ name, ok: true, ms: Date.now() - started }); console.log(` ✔ ${name}`); } catch (err) { - results.push({ name, ok: false, ms: Date.now() - started, error: err.message }); - console.log(` ✘ ${name}\n ${err.message}`); - // GitHub annotation, so the failure shows on the run summary - if (process.env.GITHUB_ACTIONS) console.log(`::error title=${title}: ${name}::${err.message.replace(/\r?\n/g, ' ')}`); + const upstream = err instanceof UpstreamError; + results.push({ name, ok: false, upstream, ms: Date.now() - started, error: err.message }); + console.log(` ${upstream ? '⚠ [upstream unavailable]' : '✘'} ${name}\n ${err.message.replace(/\n/g, '\n ')}`); + if (process.env.GITHUB_ACTIONS) { + const heading = upstream ? `Upstream unavailable (${err.service}), not a Craftbox failure` : title; + console.log(`::error title=${escapeProperty(`${heading}: ${name}`)}::${escapeData(err.message)}`); + } } }, get failed() { @@ -219,12 +369,20 @@ export function createRunner(title) { }, finish() { const failed = this.failed; - console.log(`\n${title}: ${results.length - failed}/${results.length} passed`); + const outages = results.filter((r) => r.upstream).length; + console.log(`\n${title}: ${results.length - failed}/${results.length} passed` + + (outages ? ` (${outages} blocked by an upstream outage, not Craftbox)` : '')); if (process.env.GITHUB_STEP_SUMMARY) { - const rows = results.map((r) => - `| ${r.ok ? '✅' : '❌'} | ${r.name} | ${(r.ms / 1000).toFixed(1)}s | ${r.ok ? '' : (r.error || '').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ')} |`); - fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, - `### ${title}\n\n| | Check | Time | Error |\n|---|---|---|---|\n${rows.join('\n')}\n\n`); + const cell = (s) => (s || '').replace(/\|/g, '\\|').replace(/\r?\n/g, '
'); + const row = (r) => `| ${r.ok ? '✅' : r.upstream ? '⚠️' : '❌'} | ${cell(r.name)} | ${(r.ms / 1000).toFixed(1)}s | ${r.ok ? '' : cell(r.error)} |`; + const table = (rows) => `| | Check | Time | Error |\n|---|---|---|---|\n${rows.map(row).join('\n')}\n\n`; + let md = `### ${title}\n\n${table(results.filter((r) => !r.upstream))}`; + if (outages) { + md += `#### ⚠️ Upstream unavailable\n\nThese checks couldn't run because a service outside Craftbox ` + + `was down, even after retries. They are not Craftbox failures; re-run the job once it's back.\n\n` + + table(results.filter((r) => r.upstream)); + } + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, md); } process.exitCode = failed > 0 ? 1 : 0; } diff --git a/test/ci/server-start.mjs b/test/ci/server-start.mjs index dc13066..b26edf4 100644 --- a/test/ci/server-start.mjs +++ b/test/ci/server-start.mjs @@ -9,7 +9,8 @@ // invocations can share one panel. import { - waitForPanel, bootstrapApiKey, apiClient, waitForState, + waitForPanel, bootstrapApiKey, apiClient, waitForState, provisionServer, + withUpstream, transientError, TYPE_UPSTREAMS, createRunner, assert, assertStatus, sleep } from './lib.mjs'; @@ -47,33 +48,38 @@ for (const type of types) { let started = false; await run.step(`${type}: resolve the latest stable version`, async () => { - const res = await api('GET', `/versions?type=${encodeURIComponent(type)}`); - assertStatus(res, 200, 'versions'); - version = res.body.latest; + await withUpstream(TYPE_UPSTREAMS[type], async () => { + const res = await api('GET', `/versions?type=${encodeURIComponent(type)}`); + // Craftbox answers 500 when it couldn't reach the upstream + if (res.status === 500) throw transientError(`versions: HTTP 500 ${res.text}`); + assertStatus(res, 200, 'versions'); + version = res.body.latest; + }); assert(version, 'no stable version published'); console.log(` latest: ${version}`); }); if (!version) continue; await run.step(`${type} ${version}: provision`, async () => { - const res = await api('POST', '/servers', { + const server = await provisionServer(api, { name: `CI ${type}`, serverType: type, version, port: 25565, memory: 2048, eula: true - }); - assertStatus(res, 201, 'create'); - id = res.body.server.id; - const server = await waitForState(api, id, ['stopped'], { timeoutMs: PROVISION_TIMEOUT, label: type }); + }, { timeoutMs: PROVISION_TIMEOUT, label: type }); + id = server.id; console.log(` build: ${server.build ?? 'n/a'}, Java ${server.javaMajor ?? '?'}`); }); if (!id) continue; await run.step(`${type} ${version}: start and reach "Done"`, async () => { - assertStatus(await api('POST', `/servers/${id}/start`), 200, 'start'); - try { - await waitForState(api, id, ['running'], { timeoutMs: START_TIMEOUT, label: type }); - } catch (err) { - throw new Error(`${err.message}\n--- console (tail) ---\n${await consoleTail(api, id)}`); - } + // Paper and its forks fetch Mojang's jar on their first start + await withUpstream(['mojang'], async () => { + assertStatus(await api('POST', `/servers/${id}/start`), 200, 'start'); + try { + await waitForState(api, id, ['running'], { timeoutMs: START_TIMEOUT, label: type }); + } catch (err) { + throw new Error(`${err.message}\n--- console (tail) ---\n${await consoleTail(api, id, 30)}`); + } + }); started = true; }); From a3066b7d2174ba3b4446421b6cabbca797da5fc5 Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Mon, 28 Sep 2026 22:00:41 +0100 Subject: [PATCH 04/13] ci: Check every page, signing in and out, CSRF and the status pages No page had been rendered in CI beyond three form checks, and nothing covered how the panel handles a browser's session. The new web test runs on a fresh instance, over plain HTTP: - before setup, pages go to the wizard and the API answers 503; each of the setup form's validation messages is shown, a form without its CSRF token is refused, and setup can't be run twice; - every page renders with its title and in full, and every script, stylesheet and image they load is served with the right type; the security headers are sent and the inline script carries the CSP nonce; unknown servers, bad paths and a vanilla server's plugins page get the error pages, and signed-out visitors are sent to sign in; - a wrong password is turned away with its message; signing in changes the session id and returns to the page that asked; the cookie is HttpOnly, SameSite=Strict and refreshed to an hour on each request; - the API takes a session for reads, but a change without the page's token, with a wrong one or with another session's is a 403, as is a signed-in form posted without it; signing out needs the token; - a session aged past its expiry in the container's database is signed out, and a form posted from it goes to sign in rather than a 403; - /status lists only servers whose status page is public, while any server's page and JSON are reachable by its link as documented, with only the documented fields, and a provisioning server shows as stopped; the client-mods zip leaves out server-only mods and carries an exact Content-Length; - last, since it locks the address out for 15 minutes, the sixth sign-in inside the window is refused with 429, even with the right password. It runs as the first entry of a new matrix job, with the container's name passed in for the checks that reach inside it. lib.mjs gains a browser-like session (bootstrapPanel now builds on it), loginPanel(), test jars for each loader, a zip listing, the container helpers and a WebSocket client (openSocket, upgradeStatus) for the checks that follow. --- .github/workflows/ci.yml | 44 +++++ test/ci/lib.mjs | 243 +++++++++++++++++++++------ test/ci/web.mjs | 345 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 585 insertions(+), 47 deletions(-) create mode 100644 test/ci/web.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f096a14..9e87975 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,6 +97,50 @@ jobs: if: failure() run: docker logs craftbox + # The rest of the panel, each against a fresh instance of its own. They can + # reach into the container (CRAFTBOX_CONTAINER) to age a session, kill a + # JVM or restart the panel. + checks: + name: ${{ matrix.name }} test + needs: build + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - name: Web + script: web.mjs + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Download image + uses: actions/download-artifact@v4 + with: + name: craftbox-image + path: ${{ runner.temp }} + + - name: Start Craftbox + run: | + docker load --input "$RUNNER_TEMP/craftbox-image.tar" + docker run -d --name craftbox -p 6464:6464 craftbox:ci + + - name: Run ${{ matrix.name }} test + run: node test/ci/${{ matrix.script }} + env: + CRAFTBOX_URL: http://localhost:6464 + CRAFTBOX_CONTAINER: craftbox + + - name: Craftbox log + if: failure() + run: docker logs craftbox + servers: name: Start ${{ matrix.type }} (latest) needs: build diff --git a/test/ci/lib.mjs b/test/ci/lib.mjs index 93ce399..da90409 100644 --- a/test/ci/lib.mjs +++ b/test/ci/lib.mjs @@ -3,7 +3,10 @@ // (22+, for fetch/FormData) and the panel's URL. import fs from 'node:fs'; +import http from 'node:http'; +import crypto from 'node:crypto'; import zlib from 'node:zlib'; +import { execFileSync } from 'node:child_process'; export const BASE_URL = (process.env.CRAFTBOX_URL || 'http://localhost:6464').replace(/\/$/, ''); const API = `${BASE_URL}/api/v1`; @@ -160,12 +163,53 @@ function cookieJar() { }; } +// The token pages embed for forms (`_csrf`) or for scripts (#csrf-token) function csrfFrom(html) { - const match = /name="_csrf" value="([^"]+)"/.exec(html); - if (!match) throw new Error('No CSRF token on the page.'); - return match[1]; + return /name="_csrf" value="([^"]+)"/.exec(html)?.[1] || /id="csrf-token" value="([^"]+)"/.exec(html)?.[1] || null; } +// A browser-like session: it keeps its cookies, doesn't follow redirects, +// and remembers the CSRF token of the last page it rendered, which it sends +// with its next form post (`_csrf`) or API call (X-CSRF-Token) unless told +// `csrf: false`, or given a `csrf` of its own. Responses resolve to +// {status, headers, location, text, body} with `body` parsed when JSON. +export function createSession() { + const jar = cookieJar(); + let token = null; + const session = { + get csrf() { return token; }, + get cookie() { return jar.header(); }, + async request(method, path, { form, json, headers = {}, csrf = token } = {}) { + const init = { method, redirect: 'manual', headers: { cookie: jar.header(), ...headers } }; + if (form) { + init.headers['content-type'] = 'application/x-www-form-urlencoded'; + init.body = new URLSearchParams(csrf ? { _csrf: csrf, ...form } : form); + } else { + if (method !== 'GET' && csrf) init.headers['x-csrf-token'] = csrf; + if (json instanceof FormData) { + init.body = json; + } else if (json !== undefined) { + init.headers['content-type'] = 'application/json'; + init.body = JSON.stringify(json); + } + } + const res = await fetch(BASE_URL + path, init); + jar.store(res); + const text = await res.text(); + token = csrfFrom(text) || token; + let body = null; + try { body = text ? JSON.parse(text) : null; } catch { /* a page */ } + return { status: res.status, headers: res.headers, location: res.headers.get('location') || '', text, body }; + }, + get: (path, opts) => session.request('GET', path, opts), + post: (path, form, opts) => session.request('POST', path, { form, ...opts }), + api: (method, path, json, opts) => session.request(method, `/api/v1${path}`, { json, ...opts }) + }; + return session; +} + +export const CI_USER = { username: 'ci-admin', password: 'ci-password-123' }; + // Run the first-run setup wizard on a fresh instance and mint an API key. // Returns the raw `cbx_` key. Keys can only be created from a session, so this // goes through the same forms a browser would. @@ -173,60 +217,109 @@ export async function bootstrapApiKey(credentials) { return (await bootstrapPanel(credentials)).key; } -// As bootstrapApiKey, plus `page(path)`: a GET on the logged-in session -// resolving to {status, html}, for checking what the panel's pages render. -export async function bootstrapPanel({ username = 'ci-admin', password = 'ci-password-123' } = {}) { - const jar = cookieJar(); - const page = async (path) => { - const res = await fetch(BASE_URL + path, { headers: { cookie: jar.header() }, redirect: 'manual' }); - jar.store(res); - return res; - }; - - const setupPage = await page('/setup'); +// As bootstrapApiKey, plus the logged-in `session` and `page(path)`, a GET on +// it resolving to {status, html}, for checking what the panel's pages render. +export async function bootstrapPanel({ username, password } = CI_USER) { + const session = createSession(); + const setupPage = await session.get('/setup'); if (setupPage.status !== 200) { throw new Error(`Expected a fresh instance, but /setup returned ${setupPage.status} (setup already done?).`); } - const setupRes = await fetch(`${BASE_URL}/setup`, { - method: 'POST', - redirect: 'manual', - headers: { cookie: jar.header(), 'content-type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - _csrf: csrfFrom(await setupPage.text()), - username, - password, - confirmPassword: password - }) - }); - jar.store(setupRes); - if (setupRes.status !== 302 || !/dashboard/.test(setupRes.headers.get('location') || '')) { - throw new Error(`Setup failed: ${setupRes.status} → ${setupRes.headers.get('location')}`); + const setupRes = await session.post('/setup', { username, password, confirmPassword: password }); + if (setupRes.status !== 302 || !/dashboard/.test(setupRes.location)) { + throw new Error(`Setup failed: ${setupRes.status} → ${setupRes.location}`); } + return { key: await mintApiKey(session), session, page: (path) => pageOf(session, path) }; +} + +// Log in to a panel that's already set up (each attempt counts towards the +// login rate limit of 5 per 15 minutes) and mint an API key. +export async function loginPanel({ username, password } = CI_USER) { + const session = createSession(); + await session.get('/login'); + const res = await session.post('/login', { username, password }); + if (res.status !== 302 || /login/.test(res.location)) { + throw new Error(`Login failed: ${res.status} → ${res.location}`); + } + return { key: await mintApiKey(session), session, page: (path) => pageOf(session, path) }; +} + +async function pageOf(session, path) { + const res = await session.get(path); + return { status: res.status, html: res.text }; +} +async function mintApiKey(session) { // Logging in regenerates the session, so fetch a token for the new one - const account = await page('/account'); - const keyRes = await fetch(`${API}/account/apikeys`, { - method: 'POST', - headers: { - cookie: jar.header(), - 'content-type': 'application/json', - 'x-csrf-token': csrfFrom(await account.text()) - }, - body: JSON.stringify({ name: 'CI' }) - }); - const body = await keyRes.json(); - if (keyRes.status !== 201 || !body.key) { - throw new Error(`Could not create an API key: ${keyRes.status} ${JSON.stringify(body)}`); + await session.get('/account'); + const res = await session.api('POST', '/account/apikeys', { name: 'CI' }); + if (res.status !== 201 || !res.body?.key) { + throw new Error(`Could not create an API key: ${res.status} ${res.text}`); } - return { - key: body.key, - page: async (path) => { - const res = await page(path); - return { status: res.status, html: await res.text() }; + return res.body.key; +} + +// ── WebSocket ── + +// A panel socket that keeps every message it receives, so a check can wait +// for the one it expects (already arrived or still to come) rather than +// racing the server. `cookie` is a logged-in session's; `path` '/ws/status' +// opens the public socket. +export async function openSocket({ cookie, path = '/' } = {}) { + const ws = new WebSocket(BASE_URL.replace(/^http/, 'ws') + path, cookie ? { headers: { cookie } } : undefined); + const messages = []; + const waiters = new Set(); + ws.addEventListener('message', (event) => { + let msg; + try { msg = JSON.parse(event.data); } catch { return; } + messages.push(msg); + for (const waiter of waiters) { + if (waiter.match(msg)) { + waiters.delete(waiter); + waiter.resolve(msg); + } } + }); + await new Promise((resolve, reject) => { + ws.addEventListener('open', resolve, { once: true }); + ws.addEventListener('error', () => reject(new Error(`WebSocket ${path} did not open`)), { once: true }); + }); + return { + messages, + send: (msg) => ws.send(typeof msg === 'string' ? msg : JSON.stringify(msg)), + // Where the message list is now, to wait only for what comes after + mark: () => messages.length, + waitFor(match, { timeoutMs = 30_000, what = 'matching message', since = 0 } = {}) { + const found = messages.slice(since).find(match); + if (found) return Promise.resolve(found); + return new Promise((resolve, reject) => { + const waiter = { match, resolve }; + waiters.add(waiter); + setTimeout(() => { + if (waiters.delete(waiter)) reject(new Error(`no ${what} within ${timeoutMs / 1000}s`)); + }, timeoutMs); + }); + }, + close: () => ws.close() }; } +// The HTTP status a WebSocket upgrade request gets, for checking refusals +export function upgradeStatus(path = '/', headers = {}) { + return new Promise((resolve, reject) => { + const req = http.request(BASE_URL + path, { + headers: { + connection: 'Upgrade', upgrade: 'websocket', 'sec-websocket-version': '13', + 'sec-websocket-key': crypto.randomBytes(16).toString('base64'), ...headers + } + }); + req.on('upgrade', (res, socket) => { socket.destroy(); resolve(res.statusCode); }); + req.on('response', (res) => { res.resume(); resolve(res.statusCode); }); + req.on('error', reject); + req.end(); + }); +} + // ── API client ── export function apiClient(key) { @@ -292,8 +385,57 @@ export async function provisionServer(api, body, { }); } +// ── The container ── + +// Checks that reach inside the panel's container (killing a JVM, restarting +// the panel, ageing a session) need its name in CRAFTBOX_CONTAINER, and are +// skipped without it. +export const CONTAINER = process.env.CRAFTBOX_CONTAINER || null; + +export function docker(args) { + return execFileSync('docker', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); +} + +// Run a command in the container as the panel's own user, from /app, so a +// `node -e` there can require the panel's modules +export function containerExec(args) { + return docker(['exec', '-u', 'craftbox', '-w', '/app', CONTAINER, ...args]); +} + // ── Test fixtures ── +// A jar that passes for a plugin or mod of `serverType`'s loader: the +// loader's metadata file and nothing else. Enough for uploads, listings and +// downloads, not for a server to load. +export function makeModJar(serverType, id = 'ci_mod') { + const metadata = { + fabric: ['fabric.mod.json', JSON.stringify({ schemaVersion: 1, id, version: '1.0.0', name: 'CI Mod', environment: '*' })], + forge: ['META-INF/mods.toml', `modLoader="javafml"\nloaderVersion="[1,)"\nlicense="MIT"\n[[mods]]\nmodId="${id}"\nversion="1.0.0"\n`], + neoforge: ['META-INF/neoforge.mods.toml', `modLoader="javafml"\nloaderVersion="[1,)"\nlicense="MIT"\n[[mods]]\nmodId="${id}"\nversion="1.0.0"\n`], + paper: ['plugin.yml', `name: ${id}\nversion: 1.0.0\nmain: ci.${id}.Plugin\napi-version: '1.20'\n`] + }; + const [name, content] = metadata[serverType] || metadata[{ purpur: 'paper', folia: 'paper' }[serverType]]; + return makeZip({ 'META-INF/MANIFEST.MF': 'Manifest-Version: 1.0\n', [name]: content }); +} + +// The names in a zip's central directory, for checking what an archive holds +export function zipEntries(buf) { + const end = buf.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06])); + if (end < 0) throw new Error('not a zip: no end of central directory'); + const count = buf.readUInt16LE(end + 10); + let offset = buf.readUInt32LE(end + 16); + const names = []; + for (let i = 0; i < count; i++) { + if (buf.readUInt32LE(offset) !== 0x02014b50) throw new Error('corrupt central directory'); + const nameLen = buf.readUInt16LE(offset + 28); + const extraLen = buf.readUInt16LE(offset + 30); + const commentLen = buf.readUInt16LE(offset + 32); + names.push(buf.toString('utf8', offset + 46, offset + 46 + nameLen)); + offset += 46 + nameLen + extraLen + commentLen; + } + return names; +} + // A zip of `entries` ({name: string | Buffer}), stored uncompressed: enough for // a test .mrpack without a zip dependency (zlib.crc32 needs Node 22.2+). export function makeZip(entries) { @@ -364,6 +506,12 @@ export function createRunner(title) { } } }, + // A check that can't run in this environment, such as one that needs + // the container when the panel isn't running in Docker + skip(name, why) { + results.push({ name, ok: true, skipped: why, ms: 0 }); + console.log(` - ${name} (skipped: ${why})`); + }, get failed() { return results.filter((r) => !r.ok).length; }, @@ -374,7 +522,8 @@ export function createRunner(title) { (outages ? ` (${outages} blocked by an upstream outage, not Craftbox)` : '')); if (process.env.GITHUB_STEP_SUMMARY) { const cell = (s) => (s || '').replace(/\|/g, '\\|').replace(/\r?\n/g, '
'); - const row = (r) => `| ${r.ok ? '✅' : r.upstream ? '⚠️' : '❌'} | ${cell(r.name)} | ${(r.ms / 1000).toFixed(1)}s | ${r.ok ? '' : cell(r.error)} |`; + const row = (r) => `| ${r.skipped ? '⏭️' : r.ok ? '✅' : r.upstream ? '⚠️' : '❌'} | ${cell(r.name)} | ` + + `${(r.ms / 1000).toFixed(1)}s | ${r.skipped ? `skipped: ${cell(r.skipped)}` : r.ok ? '' : cell(r.error)} |`; const table = (rows) => `| | Check | Time | Error |\n|---|---|---|---|\n${rows.map(row).join('\n')}\n\n`; let md = `### ${title}\n\n${table(results.filter((r) => !r.upstream))}`; if (outages) { diff --git a/test/ci/web.mjs b/test/ci/web.mjs new file mode 100644 index 0000000..bb8f429 --- /dev/null +++ b/test/ci/web.mjs @@ -0,0 +1,345 @@ +// Web test: the panel as a browser sees it, over plain HTTP on a fresh +// instance. The setup wizard's validation, then every page rendering (and +// every script and stylesheet they load), signing in and out, CSRF, the +// session cookie and its expiry, the public status pages, and last, because +// it locks this address out of signing in for 15 minutes, the login rate +// limit. +// +// CRAFTBOX_URL=http://localhost:6464 CRAFTBOX_CONTAINER=craftbox node test/ci/web.mjs +// +// CRAFTBOX_CONTAINER is optional; without it the session-expiry check, which +// ages the session in the container's database, is skipped. + +import { + BASE_URL, CONTAINER, CI_USER, waitForPanel, createSession, apiClient, provisionServer, + containerExec, makeModJar, zipEntries, createRunner, assert, assertStatus +} from './lib.mjs'; + +const PROVISION_TIMEOUT = 10 * 60_000; +const UNKNOWN_ID = '00000000-0000-4000-8000-000000000000'; + +const run = createRunner('Web test'); +await waitForPanel(); + +const anon = createSession(); +const title = (html) => /([^<]*)<\/title>/.exec(html)?.[1]?.replace(/&/g, '&'); +const flash = (html) => /text-bg-danger[\s\S]*?<\/span>\s*([^<]+?)\s*<\/div>/.exec(html)?.[1]; +const sidOf = (cookie) => /connect\.sid=([^;]+)/.exec(cookie)?.[1]; + +console.log('Before setup'); +await run.step('sends every page to the setup wizard and refuses the API', async () => { + for (const path of ['/', '/dashboard', '/login', '/servers/create']) { + const res = await anon.get(path); + assert(res.status === 302 && res.location === '/setup', `${path}: ${res.status} → ${res.location}`); + } + const api = await anon.get('/api/v1/servers'); + assert(api.status === 503 && api.body?.error === 'setup_required', `API: ${api.status} ${api.text}`); + assertStatus(await anon.get('/status'), 200, 'the status index needs no setup'); +}); + +const setup = createSession(); +await run.step('validates the setup form', async () => { + assertStatus(await setup.get('/setup'), 200, 'setup page'); + const cases = [ + ['nothing', { username: '', password: '', confirmPassword: '' }, 'All fields are required.'], + ['a short username', { username: 'ab', password: 'long-enough-1', confirmPassword: 'long-enough-1' }, 'Username must be 3-32 characters.'], + ['a long username', { username: 'a'.repeat(33), password: 'long-enough-1', confirmPassword: 'long-enough-1' }, 'Username must be 3-32 characters.'], + ['a bad character', { username: 'bad name', password: 'long-enough-1', confirmPassword: 'long-enough-1' }, 'Username can only contain letters, numbers, hyphens, and underscores.'], + ['a short password', { username: 'ci-admin', password: 'short', confirmPassword: 'short' }, 'Password must be at least 8 characters.'], + ['mismatched passwords', { username: 'ci-admin', password: 'long-enough-1', confirmPassword: 'long-enough-2' }, 'Passwords do not match.'] + ]; + for (const [what, form, message] of cases) { + const res = await setup.post('/setup', form); + assert(res.status === 302 && res.location === '/setup', `${what}: ${res.status} → ${res.location}`); + const shown = flash((await setup.get('/setup')).text); + assert(shown === message, `${what}: showed ${JSON.stringify(shown)}, expected ${JSON.stringify(message)}`); + } +}); +await run.step('refuses a setup form posted without its CSRF token', async () => { + const res = await setup.post('/setup', { username: 'intruder', password: 'long-enough-1', confirmPassword: 'long-enough-1' }, { csrf: null }); + assert(res.status === 302 && res.location === '/login', `${res.status} → ${res.location}`); + assertStatus(await anon.get('/setup'), 200, 'setup still open'); +}); + +console.log('Setup'); +let session = null; +let api = null; +await run.step('creates the admin account and signs in', async () => { + const before = sidOf(setup.cookie); + const res = await setup.post('/setup', { ...CI_USER, confirmPassword: CI_USER.password }); + assert(res.status === 302 && res.location === '/dashboard', `${res.status} → ${res.location}`); + assert(sidOf(setup.cookie) !== before, 'signing in kept the pre-login session id'); + assertStatus(await setup.get('/dashboard'), 200, 'dashboard after setup'); + session = setup; + await session.get('/account'); + const key = await session.api('POST', '/account/apikeys', { name: 'CI' }); + assertStatus(key, 201, 'API key'); + api = apiClient(key.body.key); +}); +if (!api) { + run.finish(); + process.exit(); +} +await run.step('won\'t run setup a second time', async () => { + const fresh = createSession(); + assert((await fresh.get('/setup')).location === '/dashboard', 'setup page still offered'); + await fresh.get('/login'); + const res = await fresh.post('/setup', { username: 'second-admin', password: 'long-enough-1', confirmPassword: 'long-enough-1' }); + assert(res.status === 302 && res.location !== '/setup', `second setup: ${res.status} → ${res.location}`); +}); + +console.log('Pages'); +let vanilla = null; +let fabric = null; +await run.step('creates a vanilla and a Fabric server to show', async () => { + const base = { version: 'latest', memory: 1024, eula: true }; + vanilla = await provisionServer(api, { ...base, name: 'CI Web', serverType: 'vanilla', port: 25565, group: 'CI Group' }, + { timeoutMs: PROVISION_TIMEOUT, label: 'vanilla' }); + let sawStoppedWhileProvisioning = null; + fabric = await provisionServer(api, { ...base, name: 'CI Web Fabric', serverType: 'fabric', port: 25566 }, { + timeoutMs: PROVISION_TIMEOUT, label: 'fabric', + // The public view reports internal states as stopped + onCreated: async (res) => { + sawStoppedWhileProvisioning = (await anon.get(`/status/${res.body.server.id}/api`)).body?.server?.state; + } + }); + assert(sawStoppedWhileProvisioning === 'stopped', `a provisioning server showed publicly as "${sawStoppedWhileProvisioning}"`); + assertStatus(await api('POST', `/servers/${vanilla.id}/files/mkdir`, { name: 'ci-dir' }), [200, 201], 'mkdir'); +}); + +const pages = () => vanilla && fabric ? [ + ['/dashboard', 'Dashboard'], + ['/dashboard/groups/CI%20Group', 'CI Group'], + ['/servers/create', 'Create Server'], + [`/servers/${vanilla.id}`, 'CI Web Console'], + [`/servers/${vanilla.id}/edit`, 'CI Web Settings'], + [`/servers/${vanilla.id}/properties`, 'CI Web Properties'], + [`/servers/${vanilla.id}/files`, 'CI Web Files'], + [`/servers/${vanilla.id}/files/ci-dir`, 'CI Web Files'], + [`/servers/${vanilla.id}/edit-file?path=server.properties`, 'CI Web | Edit server.properties'], + [`/servers/${vanilla.id}/backups`, 'CI Web Backups'], + [`/servers/${vanilla.id}/events`, 'CI Web Events'], + [`/servers/${fabric.id}/plugins`, 'CI Web Fabric Mods'], + ['/modpacks', 'Modpacks'], + ['/templates', 'Templates'], + ['/account', 'Account Settings'], + ['/status', 'Server Status'], + [`/status/${vanilla.id}`, 'CI Web Status'] +] : []; + +const rendered = new Map(); +await run.step('renders every page', async () => { + const wrong = []; + for (const [path, want] of pages()) { + const res = await session.get(path); + rendered.set(path, res); + if (res.status !== 200) wrong.push(`${path}: HTTP ${res.status} ${res.location}`); + else if (title(res.text) !== `${want} | Craftbox`) wrong.push(`${path}: title "${title(res.text)}"`); + else if (!/<\/html>\s*$/.test(res.text)) wrong.push(`${path}: page cut short`); + } + assert(wrong.length === 0, wrong.join('\n')); +}); +await run.step('serves every script, stylesheet and image the pages use', async () => { + const assets = new Set(); + for (const res of rendered.values()) { + for (const [, url] of res.text.matchAll(/<(?:script|img)[^>]+src="(\/[^"/][^"]*)"/g)) assets.add(url); + for (const [, url] of res.text.matchAll(/<link[^>]+href="(\/[^"/][^"]*)"/g)) assets.add(url); + } + assert(assets.size > 10, `only found ${assets.size} assets`); + const types = { js: /javascript/, css: /text\/css/, png: /image\/png/, svg: /image\/svg/, ico: /image\//, json: /json/ }; + const broken = []; + for (const url of assets) { + const res = await fetch(BASE_URL + url, { headers: { cookie: session.cookie } }); + const ext = url.split('?')[0].split('.').pop(); + await res.arrayBuffer(); + if (res.status !== 200) broken.push(`${url}: HTTP ${res.status}`); + else if (types[ext] && !types[ext].test(res.headers.get('content-type') || '')) broken.push(`${url}: ${res.headers.get('content-type')}`); + } + assert(broken.length === 0, broken.join('\n')); + console.log(` ${assets.size} assets`); +}); +await run.step('sends the security headers, with a nonce the inline script carries', async () => { + const res = rendered.get('/dashboard'); + assert(res, 'dashboard not rendered'); + const csp = res.headers.get('content-security-policy') || ''; + const nonce = /'nonce-([^']+)'/.exec(csp)?.[1]; + assert(nonce && /default-src 'self'/.test(csp) && /frame-ancestors 'none'/.test(csp), `CSP: ${csp}`); + const inline = [...res.text.matchAll(/<script(?![^>]*\bsrc=)([^>]*)>/g)].map((m) => m[1]); + assert(inline.length > 0 && inline.every((attrs) => attrs.includes(`nonce="${nonce}"`)), `inline scripts: ${JSON.stringify(inline)}`); + for (const [header, want] of [['x-frame-options', 'DENY'], ['x-content-type-options', 'nosniff']]) { + assert(res.headers.get(header) === want, `${header}: ${res.headers.get(header)}`); + } +}); +await run.step('answers bad addresses with the error pages', async () => { + const cases = vanilla ? [ + ['/no-such-page', 404, 'Not Found'], + [`/servers/${UNKNOWN_ID}`, 404, 'Not Found'], + ['/servers/not-a-uuid', 404, 'Not Found'], + [`/servers/${vanilla.id}/plugins`, 404, 'Not Found'], + [`/servers/${vanilla.id}/edit-file?path=../../craftbox.sqlite`, 403, 'Forbidden'], + [`/servers/${vanilla.id}/files/no-such-dir`, 404, 'Not Found'] + ] : []; + const wrong = []; + for (const [path, status, want] of cases) { + const res = await session.get(path); + if (res.status !== status || title(res.text) !== `${want} | Craftbox`) wrong.push(`${path}: ${res.status} "${title(res.text)}"`); + } + assert(wrong.length === 0, wrong.join('\n')); +}); +await run.step('sends signed-out visitors to sign in', async () => { + for (const [path] of pages().filter(([p]) => !p.startsWith('/status'))) { + const res = await anon.get(path); + assert(res.status === 302 && res.location === '/login', `${path}: ${res.status} → ${res.location}`); + } + const res = await anon.get('/api/v1/servers'); + assert(res.status === 401 && res.body?.error === 'unauthorized', `API: ${res.status} ${res.text}`); +}); + +console.log('Signing in'); +await run.step('turns away a wrong password', async () => { + const visitor = createSession(); + await visitor.get('/login'); + const res = await visitor.post('/login', { username: CI_USER.username, password: 'not-the-password' }); + assert(res.status === 302 && res.location === '/login', `${res.status} → ${res.location}`); + const shown = flash((await visitor.get('/login')).text); + assert(shown === 'Invalid username or password.', `showed ${JSON.stringify(shown)}`); + assert((await visitor.get('/dashboard')).location === '/login', 'signed in anyway'); +}); +let second = null; +await run.step('signs in and returns to the page that asked', async () => { + second = createSession(); + const asked = await second.get('/servers/create'); + assert(asked.location === '/login', `${asked.status} → ${asked.location}`); + await second.get('/login'); + const before = sidOf(second.cookie); + const res = await second.post('/login', CI_USER); + assert(res.status === 302 && res.location === '/servers/create', `${res.status} → ${res.location}`); + assert(sidOf(second.cookie) !== before, 'signing in kept the pre-login session id'); + assertStatus(await second.get('/servers/create'), 200, 'create page'); + assert((await second.get('/login')).location === '/dashboard', 'login page shown while signed in'); +}); +await run.step('keeps the session cookie HttpOnly, SameSite=Strict and an hour from last use', async () => { + const res = await fetch(`${BASE_URL}/dashboard`, { headers: { cookie: session.cookie }, redirect: 'manual' }); + const cookie = res.headers.getSetCookie().find((c) => c.startsWith('connect.sid=')); + assert(cookie, 'no cookie refresh on a page load'); + assert(/;\s*HttpOnly/i.test(cookie) && /;\s*SameSite=Strict/i.test(cookie), cookie); + const expires = Date.parse(/Expires=([^;]+)/i.exec(cookie)?.[1]); + const lifetime = (expires - Date.parse(res.headers.get('date'))) / 1000; + assert(lifetime > 3590 && lifetime <= 3600, `cookie expires in ${lifetime}s`); +}); + +console.log('CSRF'); +await run.step('the API takes a session, but its changes need the page\'s token', async () => { + assertStatus(await session.api('GET', '/servers'), 200, 'GET with a session'); + const path = `/servers/${vanilla?.id}/motd`; + const missing = await session.api('POST', path, { motd: 'no token' }, { csrf: null }); + assert(missing.status === 403 && missing.body?.error === 'forbidden', `no token: ${missing.status} ${missing.text}`); + const wrong = await session.api('POST', path, { motd: 'wrong token' }, { csrf: 'f'.repeat(64) }); + assert(wrong.status === 403, `wrong token: ${wrong.status}`); + const other = await session.api('POST', path, { motd: 'another session\'s token' }, { csrf: second?.csrf || 'e'.repeat(64) }); + assert(other.status === 403, `another session's token: ${other.status}`); + await session.get('/dashboard'); + assertStatus(await session.api('POST', path, { motd: 'CI web' }), 200, 'with the token'); +}); +await run.step('a signed-in form posted without the token is a 403 page', async () => { + const res = await session.post('/account', { currentPassword: CI_USER.password, newUsername: 'renamed' }, { csrf: null }); + assert(res.status === 403 && title(res.text) === 'Forbidden | Craftbox', `${res.status} "${title(res.text)}"`); + assert(res.text.includes('Invalid or missing CSRF token'), 'no explanation on the page'); +}); + +console.log('Signing out and expiry'); +await run.step('signing out needs the token, then ends the session', async () => { + const refused = await second.post('/logout', {}, { csrf: null }); + assertStatus(refused, 403, 'logout without a token'); + await second.get('/dashboard'); + const res = await second.post('/logout', {}); + assert(res.status === 302 && res.location === '/login', `${res.status} → ${res.location}`); + assert((await second.get('/dashboard')).location === '/login', 'still signed in after logout'); + assertStatus(await second.api('GET', '/servers'), 401, 'API after logout'); +}); +if (CONTAINER) { + await run.step('an expired session is signed out, and its forms go to sign in', async () => { + const stale = createSession(); + await stale.get('/login'); + const login = await stale.post('/login', CI_USER); + assert(login.status === 302 && login.location === '/dashboard', `login: ${login.status} → ${login.location}`); + await stale.get('/account'); + // Push the stored session past its expiry, as an hour's idle would + const sid = decodeURIComponent(sidOf(stale.cookie)).slice(2).split('.')[0]; + const out = containerExec(['node', '-e', ` + const db = new (require('better-sqlite3'))('data/craftbox.sqlite'); + const row = db.prepare('SELECT json FROM sessions WHERE ID = ?').get('sess_' + process.argv[1]); + if (!row) { console.log('missing'); process.exit(); } + const value = JSON.parse(row.json); + value.expires = Date.now() - 1000; + db.prepare('UPDATE sessions SET json = ? WHERE ID = ?').run(JSON.stringify(value), 'sess_' + process.argv[1]); + console.log('aged');`, sid]).trim(); + assert(out === 'aged', `could not age the session: ${out}`); + assert((await stale.get('/dashboard')).location === '/login', 'expired session still signed in'); + const form = await stale.post('/account', { currentPassword: CI_USER.password, newUsername: 'renamed' }); + assert(form.status === 302 && form.location === '/login', `form from an expired session: ${form.status} → ${form.location}`); + }); +} else { + run.skip('an expired session is signed out, and its forms go to sign in', 'CRAFTBOX_CONTAINER not set'); +} + +console.log('Status pages'); +await run.step('lists only servers whose status page is public', async () => { + assertStatus(await api('POST', `/servers/${vanilla.id}/statuspublic`, { enabled: true }), 200, 'statuspublic on'); + assertStatus(await api('POST', `/servers/${fabric.id}/statuspublic`, { enabled: false }), 200, 'statuspublic off'); + const html = (await anon.get('/status')).text; + assert(html.includes(`href="/status/${vanilla.id}"`), 'public server not listed'); + assert(!html.includes(fabric.id), 'non-public server listed'); +}); +await run.step('shows any server by its link, with nothing internal in it', async () => { + // Listing is all the flag controls; the UUID is the capability (docs/API.md) + const page = await anon.get(`/status/${fabric.id}`); + assert(page.status === 200 && title(page.text) === 'CI Web Fabric Status | Craftbox', `${page.status} "${title(page.text)}"`); + const res = await anon.get(`/status/${vanilla.id}/api`); + assertStatus(res, 200, 'status JSON'); + const keys = Object.keys(res.body.server).sort().join(); + const documented = ['advertisedIp', 'id', 'name', 'playerCount', 'players', 'port', 'serverType', 'state', 'statusPagePublic', + 'uptime', 'uptimeFormatted', 'version'].sort().join(); + assert(keys === documented, `keys: ${keys}`); + for (const path of [`/status/${UNKNOWN_ID}`, '/status/not-a-uuid', `/status/${UNKNOWN_ID}/api`, `/status/${UNKNOWN_ID}/mods`]) { + assertStatus(await anon.get(path), 404, path); + } +}); +await run.step('offers players the client-side mods as a zip', async () => { + assertStatus(await anon.get(`/status/${fabric.id}/mods`), 404, 'no mods yet'); + const form = new FormData(); + form.append('file', new Blob([makeModJar('fabric', 'ci_client')]), 'ci-client.jar'); + form.append('file', new Blob([makeModJar('fabric', 'ci_server')]), 'ci-server.jar'); + const upload = await api('POST', `/servers/${fabric.id}/plugins/upload`, form); + assert(upload.status === 200 && upload.body.count === 2, `upload: ${upload.status} ${upload.text}`); + assertStatus(await api('POST', `/servers/${fabric.id}/plugins/environment`, { filename: 'ci-server.jar', environment: 'server' }), 200, 'server-only'); + + const res = await fetch(`${BASE_URL}/status/${fabric.id}/mods`); + const zip = Buffer.from(await res.arrayBuffer()); + assert(res.status === 200, `mods zip: HTTP ${res.status}`); + assert(Number(res.headers.get('content-length')) === zip.length, `Content-Length ${res.headers.get('content-length')}, body ${zip.length}`); + assert(zipEntries(zip).join() === 'mods/ci-client.jar', `zip holds ${zipEntries(zip).join(', ')}`); + const page = (await anon.get(`/status/${fabric.id}`)).text; + assert(page.includes(`/status/${fabric.id}/mods`), 'status page has no mods download'); +}); + +// Last: from here on this address can't sign in for 15 minutes +console.log('Rate limiting'); +await run.step('refuses a sixth sign-in inside 15 minutes, even with the right password', async () => { + const visitor = createSession(); + await visitor.get('/login'); + let remaining = null; + for (let attempt = 0; attempt < 6; attempt++) { + const res = await visitor.post('/login', { username: CI_USER.username, password: 'not-the-password' }); + if (res.status === 429) break; + assert(res.headers.get('ratelimit-limit') === '5', `RateLimit-Limit ${res.headers.get('ratelimit-limit')}`); + remaining = Number(res.headers.get('ratelimit-remaining')); + } + assert(remaining === 0, `the limit ran out with ${remaining} attempts left`); + const res = await visitor.post('/login', CI_USER); + assert(res.status === 429 && title(res.text) === 'Too Many Requests | Craftbox', `right password: ${res.status} "${title(res.text)}"`); + assert(res.text.includes('Too many login attempts'), 'no explanation on the page'); + assertStatus(await session.get('/dashboard'), 200, 'a session already signed in is unaffected'); +}); + +run.finish(); From bafc2bab23fbded81f1cdf32e93358e7fd01db18 Mon Sep 17 00:00:00 2001 From: Will Knowles <will.knowles@diamonddigital.dev> Date: Mon, 28 Sep 2026 22:01:00 +0100 Subject: [PATCH 05/13] ci: Check the WebSocket protocol against a running server The panel's live pages all run on the WebSocket, and none of it was tested. The new WebSocket test follows docs/API.md's protocol section, with a vanilla server started for real: - the upgrade is refused (401) with no cookie, a forged one or an API key, and opens for a session and publicly on /ws/status; - ping gets a pong, and bad JSON, an unknown type, a subscribe without a server and a command without a line each get their error; - creating or regrouping a server tells every signed-in socket, echoing the request's X-Client-Id as the origin, and no public one; - subscribing sends a snapshot, without history or crash details on the public socket; a file change is announced with its directory; - a start streams starting then running, the console through "Done" and the started event, while the public socket gets the state and event but no console output; - a `command` message (field `line`) runs and its reply streams back; the public socket's commands are refused and never run; - a stop streams stopping then stopped; a backup is reported as an `operation`; clearing the event log is announced; and nothing more arrives after unsubscribing. --- .github/workflows/ci.yml | 2 + test/ci/websocket.mjs | 178 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 test/ci/websocket.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e87975..92a92ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,6 +111,8 @@ jobs: include: - name: Web script: web.mjs + - name: WebSocket + script: websocket.mjs steps: - name: Checkout uses: actions/checkout@v4 diff --git a/test/ci/websocket.mjs b/test/ci/websocket.mjs new file mode 100644 index 0000000..0a6c84c --- /dev/null +++ b/test/ci/websocket.mjs @@ -0,0 +1,178 @@ +// WebSocket test: the live protocol the panel's pages run on (docs/API.md, +// "WebSocket protocol"), against a vanilla server started for real. Session +// auth on the upgrade, subscribing, the console stream, `command` (whose +// field is `line`), state, event, operation and change broadcasts, and the +// public /ws/status socket's reduced view. +// +// CRAFTBOX_URL=http://localhost:6464 node test/ci/websocket.mjs + +import { + waitForPanel, bootstrapPanel, apiClient, provisionServer, waitForState, withUpstream, + openSocket, upgradeStatus, createRunner, assert, assertStatus, sleep +} from './lib.mjs'; + +const PROVISION_TIMEOUT = 10 * 60_000; +const START_TIMEOUT = 5 * 60_000; +const CLIENT_ID = 'ci-websocket-test'; + +const run = createRunner('WebSocket test'); +await waitForPanel(); +const { key, session } = await bootstrapPanel(); +const api = apiClient(key); +const tagged = (method, path, body) => api(method, path, body, { headers: { 'x-client-id': CLIENT_ID } }); + +let socket = null; +let pub = null; +let id = null; + +console.log('Connecting'); +await run.step('refuses an upgrade without a signed-in session', async () => { + assert(await upgradeStatus('/') === 401, 'no cookie'); + assert(await upgradeStatus('/', { cookie: 'connect.sid=s%3Anot-a-session.sig' }) === 401, 'forged cookie'); + assert(await upgradeStatus('/', { authorization: `Bearer ${key}` }) === 401, 'an API key is not a session'); +}); +await run.step('opens for a session, and publicly on /ws/status', async () => { + socket = await openSocket({ cookie: session.cookie }); + pub = await openSocket({ path: '/ws/status' }); +}); +if (!socket || !pub) { + run.finish(); + process.exit(); +} +await run.step('answers ping and reports protocol errors', async () => { + const reply = async (msg, what) => { + const since = socket.mark(); + socket.send(msg); + return socket.waitFor((m) => m.type === 'pong' || m.type === 'error', { since, what, timeoutMs: 5000 }); + }; + assert((await reply({ type: 'ping' }, 'pong')).type === 'pong', 'no pong'); + for (const [msg, error] of [ + ['not json', 'Invalid JSON.'], + [{ type: 'bogus' }, 'Unknown message type: bogus'], + [{ type: 'subscribe' }, 'Missing serverId.'], + [{ type: 'command', line: 'list' }, 'Missing serverId or line.'] + ]) { + const got = await reply(msg, `error for ${JSON.stringify(msg)}`); + assert(got.type === 'error' && got.message === error, `${JSON.stringify(msg)}: ${JSON.stringify(got)}`); + } +}); + +console.log('Server'); +await run.step('tells every signed-in socket, and no public one, when servers change', async () => { + const since = socket.mark(); + const pubSince = pub.mark(); + const server = await provisionServer(api, { + name: 'CI WebSocket', serverType: 'vanilla', version: 'latest', port: 25565, memory: 1536, eula: true + }, { timeoutMs: PROVISION_TIMEOUT, label: 'vanilla' }); + id = server.id; + await socket.waitFor((m) => m.type === 'dashboard-changed', { since, what: 'dashboard-changed on create' }); + const regroup = socket.mark(); + assertStatus(await tagged('POST', `/servers/${id}/group`, { group: 'CI' }), 200, 'group'); + const msg = await socket.waitFor((m) => m.type === 'dashboard-changed', { since: regroup, what: 'dashboard-changed on regroup' }); + assert(msg.origin === CLIENT_ID, `origin ${JSON.stringify(msg.origin)}, expected the request's X-Client-Id`); + assert(!pub.messages.slice(pubSince).some((m) => m.type === 'dashboard-changed'), 'the public socket was told'); +}); +if (!id) { + run.finish(); + process.exit(); +} +await run.step('sends a snapshot on subscribe, fuller for a session than in public', async () => { + socket.send({ type: 'subscribe', serverId: id }); + pub.send({ type: 'subscribe', serverId: id }); + const full = await socket.waitFor((m) => m.type === 'subscribed' && m.serverId === id, { what: 'subscribed' }); + const reduced = await pub.waitFor((m) => m.type === 'subscribed' && m.serverId === id, { what: 'public subscribed' }); + assert(full.state === 'stopped' && Array.isArray(full.history) && 'crashReason' in full, `session: ${JSON.stringify(full)}`); + assert(reduced.state === 'stopped', `public state ${reduced.state}`); + const leaked = ['history', 'crashReason', 'exitCode'].filter((k) => k in reduced); + assert(leaked.length === 0, `public snapshot has ${leaked.join(', ')}`); +}); +await run.step('refuses a command while the server is stopped', async () => { + const since = socket.mark(); + socket.send({ type: 'command', serverId: id, line: 'list' }); + const got = await socket.waitFor((m) => m.type === 'error', { since, what: 'error', timeoutMs: 5000 }); + assert(got.message === 'Server is not running.', got.message); +}); +await run.step('tells subscribers a listing changed, naming the directory', async () => { + const since = socket.mark(); + assertStatus(await tagged('POST', `/servers/${id}/files/mkdir`, { name: 'ci-ws' }), [200, 201], 'mkdir'); + const msg = await socket.waitFor((m) => m.type === 'content-changed' && m.serverId === id, { since, what: 'content-changed' }); + assert(msg.scope === 'files' && msg.path === '' && msg.origin === CLIENT_ID, JSON.stringify(msg)); +}); + +console.log('Running'); +let running = false; +await run.step('streams state changes and console output from a start', async () => { + const since = socket.mark(); + const pubSince = pub.mark(); + await withUpstream(['mojang'], async () => { + assertStatus(await api('POST', `/servers/${id}/start`), 200, 'start'); + await waitForState(api, id, ['running'], { timeoutMs: START_TIMEOUT, label: 'vanilla' }); + }); + running = true; + const states = () => socket.messages.slice(since).filter((m) => m.type === 'state' && m.serverId === id).map((m) => m.state); + await socket.waitFor((m) => m.type === 'state' && m.state === 'running', { since, what: 'state running' }); + assert(states().join() === 'starting,running', `states: ${states().join()}`); + const done = await socket.waitFor((m) => m.type === 'console' && /Done \(/.test(m.line), { since, what: 'the "Done" line' }); + assert(done.serverId === id && done.timestamp, JSON.stringify(done)); + const started = await socket.waitFor((m) => m.type === 'event' && m.eventType === 'started', { since, what: 'started event' }); + assert(started.serverId === id && started.createdAt, JSON.stringify(started)); + + await pub.waitFor((m) => m.type === 'state' && m.state === 'running', { since: pubSince, what: 'public state running' }); + await pub.waitFor((m) => m.type === 'event' && m.eventType === 'started', { since: pubSince, what: 'public started event' }); + assert(!pub.messages.slice(pubSince).some((m) => m.type === 'console'), 'console output reached the public socket'); +}); +if (running) { + await run.step('runs a `command` message and streams the reply', async () => { + const since = socket.mark(); + socket.send({ type: 'command', serverId: id, line: 'list' }); + await socket.waitFor((m) => m.type === 'console' && /There are \d+ (of a max of \d+ )?players online/i.test(m.line), + { since, what: 'the reply to "list"' }); + socket.send({ type: 'command', serverId: id, line: 'say hello from the CI WebSocket test' }); + await socket.waitFor((m) => m.type === 'console' && /hello from the CI WebSocket test/.test(m.line), { since, what: 'the "say" echo' }); + }); + await run.step('refuses commands on the public socket', async () => { + const since = pub.mark(); + pub.send({ type: 'command', serverId: id, line: 'op someone' }); + const got = await pub.waitFor((m) => m.type === 'error', { since, what: 'error', timeoutMs: 5000 }); + assert(got.message === 'Commands not available on public connections.', got.message); + await sleep(1000); + assert(!socket.messages.some((m) => m.type === 'console' && /someone/.test(m.line)), 'the command ran'); + }); + await run.step('streams the stop', async () => { + const since = socket.mark(); + assertStatus(await api('POST', `/servers/${id}/stop`), 200, 'stop'); + await socket.waitFor((m) => m.type === 'state' && m.state === 'stopped', { since, what: 'state stopped', timeoutMs: 120_000 }); + const states = socket.messages.slice(since).filter((m) => m.type === 'state').map((m) => m.state); + assert(states.join() === 'stopping,stopped', `states: ${states.join()}`); + await socket.waitFor((m) => m.type === 'event' && m.eventType === 'stopped', { since, what: 'stopped event' }); + }); +} + +console.log('Operations'); +await run.step('reports a backup as an `operation`', async () => { + const since = socket.mark(); + assertStatus(await api('POST', `/servers/${id}/backups`, { name: 'CI WebSocket' }), 202, 'backup'); + const msg = await socket.waitFor((m) => m.type === 'operation' && m.operation === 'backup' && m.status !== 'progress', + { since, what: 'backup operation', timeoutMs: 120_000 }); + assert(msg.status === 'complete' && msg.serverId === id, JSON.stringify(msg)); + const states = socket.messages.slice(since).filter((m) => m.type === 'state').map((m) => m.state); + assert(states.join() === 'backing_up,stopped', `states: ${states.join()}`); +}); +await run.step('tells subscribers when the event log is cleared', async () => { + const since = socket.mark(); + assertStatus(await api('POST', `/servers/${id}/events/clear`), 200, 'clear'); + await socket.waitFor((m) => m.type === 'events_cleared' && m.serverId === id, { since, what: 'events_cleared' }); +}); +await run.step('stops sending after unsubscribe', async () => { + socket.send({ type: 'unsubscribe', serverId: id }); + await sleep(500); + const since = socket.mark(); + assertStatus(await api('POST', `/servers/${id}/files/mkdir`, { name: 'ci-ws-2' }), [200, 201], 'mkdir'); + await sleep(2000); + const got = socket.messages.slice(since).filter((m) => m.serverId === id); + assert(got.length === 0, `still received ${got.map((m) => m.type).join(', ')}`); +}); + +socket.close(); +pub.close(); +run.finish(); From b337853dd00d7fc1cd4006a3daad0d6455115738 Mon Sep 17 00:00:00 2001 From: Will Knowles <will.knowles@diamonddigital.dev> Date: Mon, 28 Sep 2026 22:08:36 +0100 Subject: [PATCH 06/13] ci: Run the pages' scripts in headless Chrome Checking the HTML says nothing about whether the public/js scripts that drive every page actually run. The new browser test loads the panel in headless Chrome, driven over the DevTools protocol with Node's own WebSocket (chrome.mjs), so it needs no Puppeteer or Playwright, only the Chrome the runners already have. Every page must load without an uncaught exception, a console error, a CSP violation, a failed request, an error response or an error toast. Then the main flows go through the real UI: - signing in through the login form; - creating a server on the create page, with the newest version it fills in, and landing on its console; - starting it with the Start button, sending `list` from the console input and reading the reply, then stopping it; - saving a file in the editor, Properties (plainly, and behind a restore-point backup, which completes over the WebSocket), and Settings from its Advanced Options. Typing into a field a user couldn't reach, such as one in a collapsed section, is an error rather than silently doing nothing. The page list now lives in lib.mjs, shared with the web test. --- .github/workflows/ci.yml | 3 + test/ci/browser.mjs | 170 ++++++++++++++++++++++++++++ test/ci/chrome.mjs | 234 +++++++++++++++++++++++++++++++++++++++ test/ci/lib.mjs | 26 +++++ test/ci/web.mjs | 22 +--- 5 files changed, 435 insertions(+), 20 deletions(-) create mode 100644 test/ci/browser.mjs create mode 100644 test/ci/chrome.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92a92ca..f58da72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,6 +113,9 @@ jobs: script: web.mjs - name: WebSocket script: websocket.mjs + # Headless, in the Chrome the runner already has + - name: Browser + script: browser.mjs steps: - name: Checkout uses: actions/checkout@v4 diff --git a/test/ci/browser.mjs b/test/ci/browser.mjs new file mode 100644 index 0000000..0c4bd81 --- /dev/null +++ b/test/ci/browser.mjs @@ -0,0 +1,170 @@ +// Browser test: the panel in headless Chrome, so the pages' own scripts run. +// Every page must load without an uncaught exception, a console error, a CSP +// violation, a failed request or an error toast; then the main flows are +// driven through the real UI: signing in, creating a server from the create +// page, starting it and sending a console command, and saving a file, +// Properties (plainly and with a restore-point backup) and Settings. +// +// CRAFTBOX_URL=http://localhost:6464 node test/ci/browser.mjs +// +// Chrome is found on PATH or at its usual install path; CHROME_PATH overrides. + +import { + waitForPanel, bootstrapPanel, apiClient, provisionServer, waitForState, withUpstream, panelPages, + CI_USER, createRunner, assert, assertStatus +} from './lib.mjs'; +import { launchChrome } from './chrome.mjs'; + +const PROVISION_TIMEOUT = 10 * 60_000; +const START_TIMEOUT = 5 * 60_000; +const UUID = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'; + +const run = createRunner('Browser test'); +await waitForPanel(); +const { key } = await bootstrapPanel(); +const api = apiClient(key); +const browser = await launchChrome(); +const page = await browser.newPage(); + +// Whatever went wrong on the page since the last check, plus any error toast +// its scripts are showing +async function pageProblems() { + const toasts = await page.eval(`[...document.querySelectorAll('.toast.text-bg-danger')] + .map((t) => 'error toast: ' + t.textContent.trim().replace(/\\s+/g, ' '))`); + return [...page.takeProblems(), ...toasts]; +} +async function assertClean(what) { + const problems = await pageProblems(); + assert(problems.length === 0, `${what}:\n${problems.join('\n')}`); +} +const readFile = async (id, path) => (await api('GET', `/servers/${id}/file?path=${encodeURIComponent(path)}`)).body?.file?.content; + +let vanilla = null; +let fabric = null; +await run.step('sets up servers to show (over the API)', async () => { + const base = { version: 'latest', memory: 1024, eula: true }; + vanilla = await provisionServer(api, { ...base, name: 'CI Browser', serverType: 'vanilla', port: 25565, group: 'CI Group' }, + { timeoutMs: PROVISION_TIMEOUT, label: 'vanilla' }); + fabric = await provisionServer(api, { ...base, name: 'CI Browser Fabric', serverType: 'fabric', port: 25566 }, + { timeoutMs: PROVISION_TIMEOUT, label: 'fabric' }); + assertStatus(await api('POST', `/servers/${vanilla.id}/files/mkdir`, { name: 'ci-dir' }), [200, 201], 'mkdir'); +}); + +console.log('Signing in'); +let signedIn = false; +await run.step('signs in through the login form', async () => { + assert(await page.goto('/dashboard') === '/login', 'not sent to sign in'); + await page.type('#username', CI_USER.username); + await page.type('#password', CI_USER.password); + await page.waitFor(`!document.querySelector('button[type="submit"]').disabled`, { what: 'the Sign In button to enable' }); + const landed = await page.navigation(() => page.click('button[type="submit"]')); + assert(landed === '/dashboard', `landed on ${landed}`); + await assertClean('the dashboard after signing in'); + signedIn = true; +}); + +if (signedIn && vanilla && fabric) { + console.log('Pages'); + await run.step('loads every page without a script error', async () => { + const failures = []; + for (const [path] of panelPages(vanilla, fabric)) { + await page.goto(path); + const problems = await pageProblems(); + if (problems.length) failures.push(`${path}\n ${problems.join('\n ')}`); + } + assert(failures.length === 0, failures.join('\n')); + }); + + console.log('Flows'); + let created = null; + await run.step('creates a server from the create page', async () => { + await page.goto('/servers/create'); + await page.waitFor(`document.getElementById('version').value`, { what: 'the newest version to be filled in' }); + await page.type('#name', 'CI Browser Created'); + await page.type('#port', '25567'); + await page.click('#eula'); + await page.waitFor(`!document.getElementById('create-btn').disabled`, { what: 'the Create button to enable' }); + const landed = await page.navigation(() => page.click('#create-btn')); + created = new RegExp(`^/servers/(${UUID})$`).exec(landed)?.[1]; + assert(created, `landed on ${landed}`); + await assertClean('the new server\'s console page'); + const server = await waitForState(api, created, ['stopped'], { timeoutMs: PROVISION_TIMEOUT, label: 'created' }); + const latest = (await api('GET', '/versions?type=vanilla')).body?.latest; + assert(server.name === 'CI Browser Created' && server.port === 25567 && server.serverType === 'vanilla' && server.version === latest, + `created ${JSON.stringify({ name: server.name, port: server.port, type: server.serverType, version: server.version })}`); + }); + + if (created) { + await run.step('starts it from its page and runs a console command', async () => { + await page.goto(`/servers/${created}`); + await withUpstream(['mojang'], async () => { + await page.waitFor(`!document.querySelector('[data-action="start"]').disabled`, { what: 'Start to enable' }); + await page.click('[data-action="start"]'); + await waitForState(api, created, ['running'], { timeoutMs: START_TIMEOUT, label: 'created' }); + }); + await page.waitFor(`!document.getElementById('console-input').disabled`, { what: 'the console input to enable' }); + await page.waitFor(`/Done \\(/.test(document.getElementById('console-output').textContent)`, { what: 'the "Done" line' }); + await page.type('#console-input', 'list'); + await page.press('Enter'); + await page.waitFor(`/There are \\d+ (of a max of \\d+ )?players online/i.test(document.getElementById('console-output').textContent)`, + { what: 'the reply to "list"' }); + assert(await page.eval(`document.getElementById('console-input').value`) === '', 'the input kept the command'); + await page.click('[data-action="stop"]'); + await waitForState(api, created, ['stopped'], { timeoutMs: 120_000, label: 'created' }); + await page.waitFor(`!document.querySelector('[data-action="start"]').disabled`, { what: 'Start to enable after stopping' }); + await assertClean('the console page'); + }); + } + + await run.step('saves a file in the editor', async () => { + assertStatus(await api('POST', `/servers/${vanilla.id}/edit-file`, { filePath: 'ci-dir/notes.txt', content: 'before\n' }), 200, 'seed file'); + await page.goto(`/servers/${vanilla.id}/edit-file?path=ci-dir/notes.txt`); + await page.type('#file-editor', 'after, from the browser\n'); + const landed = await page.navigation(() => page.click('button[type="submit"][form="editor-form"]')); + assert(landed === `/servers/${vanilla.id}/files/ci-dir`, `landed on ${landed}`); + const content = await readFile(vanilla.id, 'ci-dir/notes.txt'); + assert(content === 'after, from the browser\n', `file holds ${JSON.stringify(content)}`); + await assertClean('the file editor'); + }); + + await run.step('saves Properties', async () => { + await page.goto(`/servers/${vanilla.id}/properties`); + await page.type('#properties-form [name="max-players"]', '12'); + // It reloads with ?saved=1, which restart-modal.js reads and drops + const landed = await page.navigation(() => page.click('#properties-form button[type="submit"]')); + assert(landed === `/servers/${vanilla.id}/properties`, `landed on ${landed}`); + const props = await readFile(vanilla.id, 'server.properties'); + assert(/^max-players=12$/m.test(props), 'max-players not saved'); + assert(/^online-mode=true$/m.test(props) && /^pvp=true$/m.test(props), 'the save switched other toggles off'); + await assertClean('the Properties page'); + }); + + await run.step('saves Properties behind a restore-point backup', async () => { + const before = (await api('GET', `/servers/${vanilla.id}/backups`)).body.backups.length; + await page.goto(`/servers/${vanilla.id}/properties`); + await page.type('#properties-form [name="max-players"]', '13'); + await page.click('#saveBackup'); + const landed = await page.navigation(() => page.click('#properties-form button[type="submit"]'), { timeoutMs: 120_000 }); + assert(landed === `/servers/${vanilla.id}/properties`, `landed on ${landed}`); + assert(/^max-players=13$/m.test(await readFile(vanilla.id, 'server.properties')), 'max-players not saved'); + const backups = (await api('GET', `/servers/${vanilla.id}/backups`)).body.backups; + assert(backups.length === before + 1 && backups.some((b) => b.name === 'Pre-properties backup'), + `backups: ${backups.map((b) => b.name).join(', ')}`); + await assertClean('the Properties page after a restore-point save'); + }); + + await run.step('saves Settings', async () => { + await page.goto(`/servers/${vanilla.id}/edit`); + await page.click('a[href="#advancedOptions"]'); + await page.waitFor(`document.getElementById('advancedOptions').classList.contains('show')`, { what: 'Advanced Options to open' }); + await page.type('#memory', '1536'); + const landed = await page.navigation(() => page.click('#edit-server-form button[type="submit"]')); + assert(landed === `/servers/${vanilla.id}/edit`, `landed on ${landed}`); + const server = (await api('GET', `/servers/${vanilla.id}`)).body.server; + assert(server.memory === 1536 && server.name === 'CI Browser', `memory ${server.memory}, name ${server.name}`); + await assertClean('the Settings page'); + }); +} + +await browser.close(); +run.finish(); diff --git a/test/ci/chrome.mjs b/test/ci/chrome.mjs new file mode 100644 index 0000000..8fe14d7 --- /dev/null +++ b/test/ci/chrome.mjs @@ -0,0 +1,234 @@ +// A small headless Chrome driver over the DevTools protocol, using Node's +// own WebSocket: no Puppeteer, no Playwright. It launches the Chrome already +// on the machine (the GitHub runners have one), opens pages, and records +// everything a page does wrong: uncaught exceptions, console errors, CSP +// violations, failed requests and error responses. + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawn, execFileSync } from 'node:child_process'; +import { BASE_URL, sleep } from './lib.mjs'; + +function findChrome() { + const candidates = [ + process.env.CHROME_PATH, + process.env.CHROME_BIN, + 'C:/Program Files/Google/Chrome/Application/chrome.exe', + 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe', + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' + ].filter(Boolean); + for (const candidate of candidates) if (fs.existsSync(candidate)) return candidate; + for (const name of ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser']) { + try { + return execFileSync('which', [name], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); + } catch { /* not on PATH */ } + } + throw new Error('No Chrome found: set CHROME_PATH.'); +} + +export async function launchChrome() { + const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'craftbox-chrome-')); + const args = [ + '--headless=new', '--remote-debugging-port=0', `--user-data-dir=${profile}`, + '--no-first-run', '--no-default-browser-check', '--disable-extensions', '--disable-gpu', + '--window-size=1400,1000', 'about:blank' + ]; + // Ubuntu 24.04's AppArmor rules leave CI runners without a usable sandbox + if (process.platform === 'linux' && process.env.GITHUB_ACTIONS) args.unshift('--no-sandbox'); + const proc = spawn(findChrome(), args, { stdio: ['ignore', 'ignore', 'pipe'] }); + + const endpoint = await new Promise((resolve, reject) => { + let stderr = ''; + const timer = setTimeout(() => reject(new Error(`Chrome didn't start:\n${stderr}`)), 30_000); + proc.stderr.on('data', (chunk) => { + stderr += chunk; + const match = /DevTools listening on (ws:\/\/\S+)/.exec(stderr); + if (match) { + clearTimeout(timer); + resolve(match[1]); + } + }); + proc.on('exit', (code) => reject(new Error(`Chrome exited (${code}):\n${stderr}`))); + }); + + const cdp = await connect(endpoint); + return { + newPage: () => openPage(cdp), + async close() { + cdp.close(); + proc.kill(); + await sleep(500); + try { fs.rmSync(profile, { recursive: true, force: true }); } catch { /* Chrome still letting go */ } + } + }; +} + +async function connect(url) { + const ws = new WebSocket(url); + await new Promise((resolve, reject) => { + ws.addEventListener('open', resolve, { once: true }); + ws.addEventListener('error', () => reject(new Error('could not connect to Chrome')), { once: true }); + }); + let nextId = 0; + const pending = new Map(); + const listeners = []; + ws.addEventListener('message', (event) => { + const msg = JSON.parse(event.data); + if (msg.id) { + const call = pending.get(msg.id); + if (!call) return; + pending.delete(msg.id); + if (msg.error) call.reject(new Error(`${call.method}: ${msg.error.message}`)); + else call.resolve(msg.result); + return; + } + for (const l of listeners) if (l.method === msg.method && l.sessionId === msg.sessionId) l.fn(msg.params); + }); + return { + send(method, params = {}, sessionId) { + const id = ++nextId; + ws.send(JSON.stringify({ id, method, params, sessionId })); + return new Promise((resolve, reject) => pending.set(id, { resolve, reject, method })); + }, + on(method, sessionId, fn) { + listeners.push({ method, sessionId, fn }); + }, + close: () => ws.close() + }; +} + +async function openPage(cdp) { + const { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank' }); + const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true }); + const send = (method, params) => cdp.send(method, params, sessionId); + const on = (method, fn) => cdp.on(method, sessionId, fn); + + let problems = []; + let loads = 0; + const inflight = new Map(); + let lastActivity = Date.now(); + const origin = new URL(BASE_URL).origin; + + on('Runtime.exceptionThrown', ({ exceptionDetails: d }) => { + problems.push(`uncaught ${(d.exception?.description || d.text).split('\n')[0]}`); + }); + on('Runtime.consoleAPICalled', ({ type, args }) => { + if (type === 'error' || type === 'assert') { + problems.push(`console.${type}: ${args.map((a) => a.value ?? a.description).join(' ')}`); + } + }); + on('Log.entryAdded', ({ entry }) => { + // Error responses are reported below with their URL instead + if (entry.level === 'error' && entry.source !== 'network') problems.push(`${entry.source}: ${entry.text}`); + }); + on('Network.requestWillBeSent', ({ requestId, request }) => { + inflight.set(requestId, request.url); + lastActivity = Date.now(); + }); + on('Network.responseReceived', ({ response, type }) => { + if (response.status >= 400 && response.url.startsWith(origin)) { + problems.push(`HTTP ${response.status} for ${type} ${response.url.slice(origin.length)}`); + } + }); + on('Network.loadingFinished', ({ requestId }) => { + inflight.delete(requestId); + lastActivity = Date.now(); + }); + on('Network.loadingFailed', ({ requestId, errorText, canceled }) => { + const url = inflight.get(requestId); + inflight.delete(requestId); + lastActivity = Date.now(); + // Leaving a page cancels what it still had in flight + if (!canceled && errorText !== 'net::ERR_ABORTED') problems.push(`request failed: ${errorText} ${url}`); + }); + on('Page.loadEventFired', () => { loads++; }); + // Leaving a page with unsaved edits asks first; anything else is a problem + on('Page.javascriptDialogOpening', ({ type, message }) => { + if (type !== 'beforeunload') problems.push(`${type} dialog: ${message}`); + send('Page.handleJavaScriptDialog', { accept: true }).catch(() => {}); + }); + + await Promise.all(['Page.enable', 'Runtime.enable', 'Log.enable', 'Network.enable'].map((m) => send(m))); + // CSP violations as the page itself sees them, alongside Chrome's report + await send('Page.addScriptToEvaluateOnNewDocument', { + source: `document.addEventListener('securitypolicyviolation', (e) => + console.error('CSP violation: ' + e.violatedDirective + ' blocked ' + (e.blockedURI || 'inline code')));` + }); + + const page = { + // Everything recorded since the last call + takeProblems() { + const taken = problems; + problems = []; + return taken; + }, + async eval(expression) { + const { result, exceptionDetails } = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }); + if (exceptionDetails) throw new Error(`in the page: ${exceptionDetails.exception?.description || exceptionDetails.text}`); + return result.value; + }, + async waitFor(expression, { timeoutMs = 30_000, what = expression } = {}) { + const deadline = Date.now() + timeoutMs; + for (;;) { + const value = await page.eval(expression).catch(() => null); + if (value) return value; + if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`); + await sleep(250); + } + }, + // Wait until nothing has been in flight for a moment + async settle({ quietMs = 750, timeoutMs = 15_000 } = {}) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (inflight.size === 0 && Date.now() - lastActivity >= quietMs) return; + await sleep(100); + } + }, + // Resolve once the page has loaded a new document, after `action` + async navigation(action, { timeoutMs = 30_000 } = {}) { + const before = loads; + await action(); + const deadline = Date.now() + timeoutMs; + while (loads === before) { + if (Date.now() > deadline) throw new Error('no page load'); + await sleep(100); + } + await page.settle(); + return page.eval('location.pathname + location.search'); + }, + async goto(pathname) { + return page.navigation(async () => { + const { errorText } = await send('Page.navigate', { url: BASE_URL + pathname }); + if (errorText) throw new Error(`navigating to ${pathname}: ${errorText}`); + }); + }, + click: (selector) => page.eval(`(() => { + const el = document.querySelector(${JSON.stringify(selector)}); + if (!el) throw new Error('no ${selector.replace(/'/g, "\\'")}'); + el.click(); + return true; + })()`), + // Replace a field's value as typing would, firing the input events. A + // field a user couldn't type into (hidden, collapsed) is an error. + async type(selector, text) { + await page.eval(`(() => { + const el = document.querySelector(${JSON.stringify(selector)}); + if (!el) throw new Error('no ${selector.replace(/'/g, "\\'")}'); + el.focus(); + if (document.activeElement !== el) throw new Error('${selector.replace(/'/g, "\\'")} can\\'t take focus (hidden?)'); + el.value = ''; + return true; + })()`); + await send('Input.insertText', { text }); + }, + async press(key) { + const codes = { Enter: 13 }; + const base = { key, code: key, windowsVirtualKeyCode: codes[key], nativeVirtualKeyCode: codes[key] }; + await send('Input.dispatchKeyEvent', { type: 'keyDown', ...base, text: key === 'Enter' ? '\r' : undefined }); + await send('Input.dispatchKeyEvent', { type: 'keyUp', ...base }); + }, + close: () => cdp.send('Target.closeTarget', { targetId }) + }; + return page; +} diff --git a/test/ci/lib.mjs b/test/ci/lib.mjs index da90409..ff22ce2 100644 --- a/test/ci/lib.mjs +++ b/test/ci/lib.mjs @@ -259,6 +259,32 @@ async function mintApiKey(session) { return res.body.key; } +// Every page of the panel, as [path, title before " | Craftbox"], for a +// provisioned vanilla server (in group "CI Group", with a ci-dir folder) and a +// Fabric one, whose Mods page stands in for the plugins page +export function panelPages(vanilla, fabric) { + const v = `/servers/${vanilla.id}`; + return [ + ['/dashboard', 'Dashboard'], + ['/dashboard/groups/CI%20Group', 'CI Group'], + ['/servers/create', 'Create Server'], + [v, `${vanilla.name} Console`], + [`${v}/edit`, `${vanilla.name} Settings`], + [`${v}/properties`, `${vanilla.name} Properties`], + [`${v}/files`, `${vanilla.name} Files`], + [`${v}/files/ci-dir`, `${vanilla.name} Files`], + [`${v}/edit-file?path=server.properties`, `${vanilla.name} | Edit server.properties`], + [`${v}/backups`, `${vanilla.name} Backups`], + [`${v}/events`, `${vanilla.name} Events`], + [`/servers/${fabric.id}/plugins`, `${fabric.name} Mods`], + ['/modpacks', 'Modpacks'], + ['/templates', 'Templates'], + ['/account', 'Account Settings'], + ['/status', 'Server Status'], + [`/status/${vanilla.id}`, `${vanilla.name} Status`] + ]; +} + // ── WebSocket ── // A panel socket that keeps every message it receives, so a check can wait diff --git a/test/ci/web.mjs b/test/ci/web.mjs index bb8f429..d0736fc 100644 --- a/test/ci/web.mjs +++ b/test/ci/web.mjs @@ -11,7 +11,7 @@ // ages the session in the container's database, is skipped. import { - BASE_URL, CONTAINER, CI_USER, waitForPanel, createSession, apiClient, provisionServer, + BASE_URL, CONTAINER, CI_USER, waitForPanel, createSession, apiClient, provisionServer, panelPages, containerExec, makeModJar, zipEntries, createRunner, assert, assertStatus } from './lib.mjs'; @@ -107,25 +107,7 @@ await run.step('creates a vanilla and a Fabric server to show', async () => { assertStatus(await api('POST', `/servers/${vanilla.id}/files/mkdir`, { name: 'ci-dir' }), [200, 201], 'mkdir'); }); -const pages = () => vanilla && fabric ? [ - ['/dashboard', 'Dashboard'], - ['/dashboard/groups/CI%20Group', 'CI Group'], - ['/servers/create', 'Create Server'], - [`/servers/${vanilla.id}`, 'CI Web Console'], - [`/servers/${vanilla.id}/edit`, 'CI Web Settings'], - [`/servers/${vanilla.id}/properties`, 'CI Web Properties'], - [`/servers/${vanilla.id}/files`, 'CI Web Files'], - [`/servers/${vanilla.id}/files/ci-dir`, 'CI Web Files'], - [`/servers/${vanilla.id}/edit-file?path=server.properties`, 'CI Web | Edit server.properties'], - [`/servers/${vanilla.id}/backups`, 'CI Web Backups'], - [`/servers/${vanilla.id}/events`, 'CI Web Events'], - [`/servers/${fabric.id}/plugins`, 'CI Web Fabric Mods'], - ['/modpacks', 'Modpacks'], - ['/templates', 'Templates'], - ['/account', 'Account Settings'], - ['/status', 'Server Status'], - [`/status/${vanilla.id}`, 'CI Web Status'] -] : []; +const pages = () => vanilla && fabric ? panelPages(vanilla, fabric) : []; const rendered = new Map(); await run.step('renders every page', async () => { From 00a97ae7038b5ebaf1d6368e065204c2194405d5 Mon Sep 17 00:00:00 2001 From: Will Knowles <will.knowles@diamonddigital.dev> Date: Mon, 28 Sep 2026 22:20:15 +0100 Subject: [PATCH 07/13] ci: Check lifecycle, crashes, auto-start, upgrades and backups Nothing checked what the panel does to a server over time: restarts, crashes, upgrades or restoring a backup. The new operations test covers that on vanilla servers started for real, in two sections that run as separate jobs. Lifecycle: - a version upgrade from the previous release to the latest, over the WebSocket, refusing a bad version and downgrades (through Settings too); - restart, with the `restarting` flag on its stopped broadcast, restart behind a backup, and kill, which is a clean stop; - a running server refusing a backup without stopFirst, a jar upgrade, delete, rename and another start; - crash detection with the JVM made to exit 143 inside the container, its crash details kept off the public status JSON, and auto-restart; - a restore-point Settings save on a running server, which restarts it; - auto-start across a panel (container) restart, only when it's on. Backups: a backup and its download, a restore over later changes (files added since are gone), a restore-point Properties save, backing up and restoring a running server with startAfter, and the schedule and retention settings, which keep out-of-range values out and prune to the retention count. A JVM killed by a signal, as the kernel's OOM killer does, is recorded as a clean stop with no crash and no auto-restart. That is reported separately rather than fixed here, and its check runs as a known issue: the runner's new `knownIssue` option makes it a warning while it fails and a failure once it passes, so the marker gets removed with the fix. --- .github/workflows/ci.yml | 5 + test/ci/lib.mjs | 44 ++++-- test/ci/operations.mjs | 331 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 369 insertions(+), 11 deletions(-) create mode 100644 test/ci/operations.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f58da72..029511a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -116,6 +116,11 @@ jobs: # Headless, in the Chrome the runner already has - name: Browser script: browser.mjs + # The two halves of the operations test run side by side + - name: Lifecycle + script: operations.mjs lifecycle + - name: Backups + script: operations.mjs backups steps: - name: Checkout uses: actions/checkout@v4 diff --git a/test/ci/lib.mjs b/test/ci/lib.mjs index ff22ce2..e32defa 100644 --- a/test/ci/lib.mjs +++ b/test/ci/lib.mjs @@ -516,20 +516,42 @@ export function createRunner(title) { const results = []; return { - async step(name, fn) { + // `knownIssue` marks a check for a bug that's been reported but not + // fixed: while it fails it's a warning, and once it passes it fails, + // so the marker comes off. + async step(name, fn, { knownIssue } = {}) { const started = Date.now(); + let err = null; try { await fn(); - results.push({ name, ok: true, ms: Date.now() - started }); - console.log(` ✔ ${name}`); - } catch (err) { - const upstream = err instanceof UpstreamError; - results.push({ name, ok: false, upstream, ms: Date.now() - started, error: err.message }); - console.log(` ${upstream ? '⚠ [upstream unavailable]' : '✘'} ${name}\n ${err.message.replace(/\n/g, '\n ')}`); + } catch (caught) { + err = caught; + } + const ms = Date.now() - started; + const upstream = err instanceof UpstreamError; + const indent = (s) => s.replace(/\n/g, '\n '); + + if (knownIssue && err && !upstream) { + results.push({ name, ok: true, known: `${knownIssue}: ${err.message}`, ms }); + console.log(` ! ${name} (known issue: ${knownIssue})\n ${indent(err.message)}`); if (process.env.GITHUB_ACTIONS) { - const heading = upstream ? `Upstream unavailable (${err.service}), not a Craftbox failure` : title; - console.log(`::error title=${escapeProperty(`${heading}: ${name}`)}::${escapeData(err.message)}`); + console.log(`::warning title=${escapeProperty(`Known issue: ${name}`)}::${escapeData(`${knownIssue}\n${err.message}`)}`); } + return; + } + if (knownIssue && !err) { + err = new Error(`Passes now, so this looks fixed (${knownIssue}): remove its knownIssue marker.`); + } + if (!err) { + results.push({ name, ok: true, ms }); + console.log(` ✔ ${name}`); + return; + } + results.push({ name, ok: false, upstream, ms, error: err.message }); + console.log(` ${upstream ? '⚠ [upstream unavailable]' : '✘'} ${name}\n ${indent(err.message)}`); + if (process.env.GITHUB_ACTIONS) { + const heading = upstream ? `Upstream unavailable (${err.service}), not a Craftbox failure` : title; + console.log(`::error title=${escapeProperty(`${heading}: ${name}`)}::${escapeData(err.message)}`); } }, // A check that can't run in this environment, such as one that needs @@ -548,8 +570,8 @@ export function createRunner(title) { (outages ? ` (${outages} blocked by an upstream outage, not Craftbox)` : '')); if (process.env.GITHUB_STEP_SUMMARY) { const cell = (s) => (s || '').replace(/\|/g, '\\|').replace(/\r?\n/g, '<br>'); - const row = (r) => `| ${r.skipped ? '⏭️' : r.ok ? '✅' : r.upstream ? '⚠️' : '❌'} | ${cell(r.name)} | ` + - `${(r.ms / 1000).toFixed(1)}s | ${r.skipped ? `skipped: ${cell(r.skipped)}` : r.ok ? '' : cell(r.error)} |`; + const row = (r) => `| ${r.skipped ? '⏭️' : r.known ? '🐞' : r.ok ? '✅' : r.upstream ? '⚠️' : '❌'} | ${cell(r.name)} | ` + + `${(r.ms / 1000).toFixed(1)}s | ${r.skipped ? `skipped: ${cell(r.skipped)}` : r.known ? `known issue: ${cell(r.known)}` : r.ok ? '' : cell(r.error)} |`; const table = (rows) => `| | Check | Time | Error |\n|---|---|---|---|\n${rows.map(row).join('\n')}\n\n`; let md = `### ${title}\n\n${table(results.filter((r) => !r.upstream))}`; if (outages) { diff --git a/test/ci/operations.mjs b/test/ci/operations.mjs new file mode 100644 index 0000000..aad7ac5 --- /dev/null +++ b/test/ci/operations.mjs @@ -0,0 +1,331 @@ +// Operations test: what the panel does to a real server over time, on vanilla +// servers started for real. Two sections, each on a fresh instance, so CI +// can run them side by side: +// +// - lifecycle: a version upgrade (and the refusals around it), restart, +// restart behind a backup, kill, the guards on a running server, crash +// detection and auto-restart (the JVM killed inside the container), a +// restore-point settings save, and auto-start across a panel restart; +// - backups: backup, download, restore, restore-point Properties saves, +// backing up and restoring a running server, and the schedule and +// retention settings. +// +// CRAFTBOX_URL=http://localhost:6464 CRAFTBOX_CONTAINER=craftbox node test/ci/operations.mjs [lifecycle|backups] +// +// Without CRAFTBOX_CONTAINER the checks that reach into the container (the +// crash and auto-start ones) are skipped. + +import { + CONTAINER, waitForPanel, bootstrapPanel, apiClient, provisionServer, waitForState, withUpstream, + transientError, openSocket, docker, zipEntries, BASE_URL, createRunner, assert, assertStatus, sleep +} from './lib.mjs'; + +const PROVISION_TIMEOUT = 10 * 60_000; +const START_TIMEOUT = 5 * 60_000; +const sections = process.argv.slice(2).length ? process.argv.slice(2) : ['lifecycle', 'backups']; + +const run = createRunner(`Operations test (${sections.join(', ')})`); +await waitForPanel(); +const { key, session } = await bootstrapPanel(); +const api = apiClient(key); +let socket = await openSocket({ cookie: session.cookie }); + +const server = async (id) => (await api('GET', `/servers/${id}`)).body.server; +const readFile = async (id, path) => (await api('GET', `/servers/${id}/file?path=${encodeURIComponent(path)}`)).body?.file?.content; +const eventTypes = async (id) => (await api('GET', `/servers/${id}/events?limit=200`)).body.events.map((e) => e.type); +const backups = async (id) => (await api('GET', `/servers/${id}/backups`)).body.backups; + +async function start(id) { + await withUpstream(['mojang'], async () => { + assertStatus(await api('POST', `/servers/${id}/start`), 200, 'start'); + await waitForState(api, id, ['running'], { timeoutMs: START_TIMEOUT }); + }); +} +async function stop(id) { + assertStatus(await api('POST', `/servers/${id}/stop`), 200, 'stop'); + await waitForState(api, id, ['stopped'], { timeoutMs: 120_000 }); +} + +// Wait for an async operation's final message (not its progress) after `since` +async function operation(id, name, since, timeoutMs = 180_000) { + const msg = await socket.waitFor((m) => m.type === 'operation' && m.serverId === id && m.operation === name && m.status !== 'progress', + { since, what: `the ${name} operation to finish`, timeoutMs }); + assert(msg.status === 'complete', `${name} ${msg.status}: ${msg.error || JSON.stringify(msg.payload)}`); + return msg; +} + +// Signal the JVM from inside the container, as something other than the +// panel would: SIGTERM makes it exit 143, SIGKILL is what the OOM killer sends +function signalJava(signal) { + docker(['exec', CONTAINER, 'pkill', `-${signal}`, '-f', 'java']); +} + +async function vanillaVersions() { + return withUpstream(['mojang'], async () => { + const res = await api('GET', '/versions?type=vanilla'); + if (res.status === 500) throw transientError(`versions: HTTP 500 ${res.text}`); + assertStatus(res, 200, 'versions'); + return { latest: res.body.latest, stable: res.body.versions.filter((v) => v.channel === 'stable').map((v) => v.id) }; + }); +} + +// ── Lifecycle ── + +async function lifecycle() { + console.log('Lifecycle'); + const { latest, stable } = await vanillaVersions(); + const previous = stable[stable.indexOf(latest) + 1]; + let id = null; + + await run.step(`creates a vanilla server on the previous release (${previous})`, async () => { + assert(previous, 'no release before the latest'); + id = (await provisionServer(api, { + name: 'CI Lifecycle', serverType: 'vanilla', version: previous, port: 25565, memory: 1536, eula: true + }, { timeoutMs: PROVISION_TIMEOUT, label: 'vanilla' })).id; + socket.send({ type: 'subscribe', serverId: id }); + await socket.waitFor((m) => m.type === 'subscribed' && m.serverId === id, { what: 'subscribed' }); + }); + if (!id) return; + + await run.step('offers vanilla no build upgrade, and says why', async () => { + const res = await api('GET', `/servers/${id}/check-upgrade`); + assertStatus(res, 200, 'check-upgrade'); + assert(res.body.upgradeAvailable === false && res.body.reason, JSON.stringify(res.body)); + }); + await run.step(`upgrades it to ${latest}, refusing bad versions and downgrades`, async () => { + assertStatus(await api('POST', `/servers/${id}/upgrade-jar`, { version: '../1.21' }), 400, 'bad version'); + const older = stable[stable.indexOf(previous) + 1]; + const down = await api('POST', `/servers/${id}/upgrade-jar`, { version: older }); + assert(down.status === 400 && /downgrade/i.test(down.body?.error), `downgrade to ${older}: ${down.status} ${down.text}`); + const since = socket.mark(); + assertStatus(await api('POST', `/servers/${id}/upgrade-jar`, { version: latest }), 202, 'upgrade'); + const done = await withUpstream(['mojang'], () => operation(id, 'jar-upgrade', since)); + assert(done.payload?.version === latest, `operation payload ${JSON.stringify(done.payload)}`); + const s = await waitForState(api, id, ['stopped'], { timeoutMs: 60_000 }); + assert(s.version === latest && s.javaMajor, `recorded version ${s.version}, Java ${s.javaMajor}`); + assert((await eventTypes(id)).includes('jar_upgrade'), 'no jar_upgrade event'); + const edit = await api('POST', `/servers/${id}/edit`, { name: 'CI Lifecycle', port: 25565, memory: 1536, version: previous }); + assertStatus(edit, 400, 'downgrade through Settings'); + }); + + let running = false; + await run.step('starts, then restarts', async () => { + await start(id); + running = true; + const since = socket.mark(); + assertStatus(await api('POST', `/servers/${id}/restart`), 200, 'restart'); + await socket.waitFor((m) => m.type === 'state' && m.serverId === id && m.state === 'running', { since, what: 'running again', timeoutMs: START_TIMEOUT }); + const states = socket.messages.slice(since).filter((m) => m.type === 'state' && m.serverId === id).map((m) => m.state); + assert(states.join() === 'stopping,stopped,starting,running', `states: ${states.join()}`); + const stopped = socket.messages.slice(since).find((m) => m.type === 'state' && m.state === 'stopped'); + assert(stopped.restarting === true, 'the stopped broadcast of a restart didn\'t say it was restarting'); + assert((await eventTypes(id)).includes('restarted'), 'no restarted event'); + }); + if (!running) return; + + await run.step('guards a running server', async () => { + const cases = [ + ['backup without stopFirst', 'POST', `/servers/${id}/backups`, {}, 409], + ['jar upgrade', 'POST', `/servers/${id}/upgrade-jar`, {}, 409], + ['delete', 'DELETE', `/servers/${id}`, undefined, 409], + ['rename a file', 'POST', `/servers/${id}/files/rename`, { path: 'server.properties', newName: 'x.properties' }, 409], + ['start', 'POST', `/servers/${id}/start`, undefined, 400] + ]; + const wrong = []; + for (const [what, method, path, body, status] of cases) { + const res = await api(method, path, body); + if (res.status !== status) wrong.push(`${what}: ${res.status}, expected ${status}`); + } + assert(wrong.length === 0, wrong.join('\n')); + assert((await server(id)).state === 'running', 'no longer running'); + }); + await run.step('restarts behind a backup', async () => { + const since = socket.mark(); + assertStatus(await api('POST', `/servers/${id}/restart`, { backup: true }), 202, 'restart with backup'); + await operation(id, 'backup', since); + await waitForState(api, id, ['running'], { timeoutMs: START_TIMEOUT }); + assert((await backups(id)).some((b) => b.name === 'Pre-restart backup'), 'no Pre-restart backup'); + }); + await run.step('kills it', async () => { + assertStatus(await api('POST', `/servers/${id}/kill`), 200, 'kill'); + const s = await waitForState(api, id, ['stopped', 'crashed'], { timeoutMs: 60_000 }); + assert(s.state === 'stopped' && !s.crashReason, `a kill left it ${s.state} (${s.crashReason})`); + }); + + if (!CONTAINER) { + for (const name of ['detects a crash', 'restarts itself after a crash', 'detects a JVM killed by a signal', 'restores settings behind a backup while running', 'auto-starts across a panel restart']) { + run.skip(name, 'CRAFTBOX_CONTAINER not set'); + } + return; + } + + await run.step('detects a crash, and keeps its details off the status page', async () => { + await start(id); + const since = socket.mark(); + signalJava('TERM'); // the JVM exits 143 + const s = await waitForState(api, id, ['crashed', 'stopped'], { timeoutMs: 60_000 }); + assert(s.state === 'crashed' && s.crashReason === 'exit_code' && s.exitCode === 143, + `state ${s.state}, crashReason ${s.crashReason}, exitCode ${s.exitCode}`); + await socket.waitFor((m) => m.type === 'event' && m.serverId === id && m.eventType === 'crashed', { since, what: 'crashed event' }); + const pub = (await (await fetch(`${BASE_URL}/status/${id}/api`)).json()).server; + assert(pub.state === 'crashed' && !('crashReason' in pub) && !('exitCode' in pub), `public: ${JSON.stringify(pub)}`); + }); + await run.step('restarts itself after a crash when auto-restart is on', async () => { + assertStatus(await api('POST', `/servers/${id}/autorestart`, { enabled: true }), 200, 'autorestart'); + await start(id); + const since = socket.mark(); + signalJava('TERM'); + await socket.waitFor((m) => m.type === 'state' && m.serverId === id && m.state === 'crashed', { since, what: 'the crash' }); + await socket.waitFor((m) => m.type === 'state' && m.serverId === id && m.state === 'running', { since, what: 'running again', timeoutMs: START_TIMEOUT }); + }); + await run.step('detects a JVM killed by a signal (as the kernel\'s OOM killer does)', async () => { + const since = socket.mark(); + signalJava('KILL'); + await socket.waitFor((m) => m.type === 'state' && m.serverId === id && ['crashed', 'stopped'].includes(m.state), { since, what: 'the exit' }); + const s = await server(id); + assert(s.state === 'crashed' || s.state === 'starting' || s.state === 'running', + `recorded as a clean stop (state ${s.state}, exitCode ${s.exitCode}, crashReason ${s.crashReason}), so auto-restart never fires`); + await socket.waitFor((m) => m.type === 'state' && m.serverId === id && m.state === 'running', { since, what: 'the auto-restart', timeoutMs: START_TIMEOUT }); + }, { knownIssue: 'a JVM that dies from a signal exits with code null, which ServerProcess treats as a clean stop' }); + await run.step('saves settings behind a restore point while running, and restarts', async () => { + if ((await server(id)).state !== 'running') await start(id); + const since = socket.mark(); + const res = await api('POST', `/servers/${id}/edit`, { name: 'CI Lifecycle', port: 25565, memory: 1536, difficulty: 'hard', backup: true }); + assertStatus(res, 202, 'edit with backup'); + await operation(id, 'backup', since); + const saved = await operation(id, 'settings-save', since); + assert(saved.payload?.restarted === true, `payload ${JSON.stringify(saved.payload)}`); + await waitForState(api, id, ['running'], { timeoutMs: START_TIMEOUT }); + assert(/^difficulty=hard$/m.test(await readFile(id, 'server.properties')), 'difficulty not saved'); + assert((await backups(id)).some((b) => b.name === 'Pre-edit backup'), 'no Pre-edit backup'); + }); + await run.step('auto-starts across a panel restart only when asked to', async () => { + const restartPanel = async () => { + socket.close(); + docker(['restart', '-t', '60', CONTAINER]); + await waitForPanel(); + socket = await openSocket({ cookie: session.cookie }); + }; + assertStatus(await api('POST', `/servers/${id}/autostart`, { enabled: false }), 200, 'autostart off'); + await restartPanel(); + await sleep(5000); + const after = await server(id); + assert(after.state === 'stopped', `with auto-start off it came back ${after.state}`); + assert(after.name === 'CI Lifecycle' && after.version === latest, 'the record changed across the restart'); + + assertStatus(await api('POST', `/servers/${id}/autostart`, { enabled: true }), 200, 'autostart on'); + await restartPanel(); + await waitForState(api, id, ['running'], { timeoutMs: START_TIMEOUT }); + const started = (await api('GET', `/servers/${id}/events?limit=5`)).body.events.find((e) => e.type === 'started'); + assert(started?.initiatedBy === 'Auto Start', `started by ${started?.initiatedBy}`); + }); +} + +// ── Backups ── + +async function backupsSection() { + console.log('Backups'); + let id = null; + await run.step('creates a vanilla server', async () => { + id = (await provisionServer(api, { + name: 'CI Backups', serverType: 'vanilla', version: 'latest', port: 25565, memory: 1536, eula: true + }, { timeoutMs: PROVISION_TIMEOUT, label: 'vanilla' })).id; + socket.send({ type: 'subscribe', serverId: id }); + await socket.waitFor((m) => m.type === 'subscribed' && m.serverId === id, { what: 'subscribed' }); + }); + if (!id) return; + + let backupId = null; + await run.step('backs up, and serves the archive', async () => { + assertStatus(await api('POST', `/servers/${id}/edit-file`, { filePath: 'ci-restore.txt', content: 'original\n' }), 200, 'seed file'); + const since = socket.mark(); + assertStatus(await api('POST', `/servers/${id}/backups`, { name: 'CI restore point' }), 202, 'backup'); + const done = await operation(id, 'backup', since); + backupId = done.payload?.backup?.id || (await backups(id)).find((b) => b.name === 'CI restore point')?.id; + assert(backupId, 'no backup id'); + const res = await fetch(`${BASE_URL}/api/v1/servers/${id}/backups/${backupId}/download`, { headers: { authorization: `Bearer ${key}` } }); + const zip = Buffer.from(await res.arrayBuffer()); + assert(res.status === 200 && /zip/.test(res.headers.get('content-type')), `download: ${res.status} ${res.headers.get('content-type')}`); + assert(Number(res.headers.get('content-length')) === zip.length, 'Content-Length doesn\'t match the body'); + const names = zipEntries(zip); + assert(names.includes('ci-restore.txt') && names.includes('server.properties'), `archive holds ${names.slice(0, 10).join(', ')}…`); + }); + await run.step('restores it over later changes', async () => { + assertStatus(await api('POST', `/servers/${id}/edit-file`, { filePath: 'ci-restore.txt', content: 'changed\n' }), 200, 'change'); + assertStatus(await api('POST', `/servers/${id}/edit-file`, { filePath: 'ci-extra.txt', content: 'added later\n' }), 200, 'add'); + const since = socket.mark(); + assertStatus(await api('POST', `/servers/${id}/backups/${backupId}/restore`), 202, 'restore'); + await operation(id, 'restore', since); + await waitForState(api, id, ['stopped'], { timeoutMs: 60_000 }); + assert(await readFile(id, 'ci-restore.txt') === 'original\n', 'file not restored'); + assertStatus(await api('GET', `/servers/${id}/file?path=ci-extra.txt`), 404, 'a file added after the backup'); + assert((await eventTypes(id)).includes('backup_restore'), 'no backup_restore event'); + }); + await run.step('saves Properties behind a restore point', async () => { + const since = socket.mark(); + assertStatus(await api('POST', `/servers/${id}/properties`, { 'max-players': 9, backup: true }), 202, 'properties with backup'); + await operation(id, 'backup', since); + const saved = await operation(id, 'settings-save', since); + assert(saved.payload?.restarted === false, `a stopped server was restarted: ${JSON.stringify(saved.payload)}`); + const props = await readFile(id, 'server.properties'); + assert(/^max-players=9$/m.test(props) && !/^backup=/m.test(props), 'not saved, or `backup` written as a property'); + assert((await backups(id)).some((b) => b.name === 'Pre-properties backup'), 'no Pre-properties backup'); + }); + await run.step('backs up and restores a running server, starting it again after', async () => { + await start(id); + let since = socket.mark(); + assertStatus(await api('POST', `/servers/${id}/backups`, { name: 'CI live', stopFirst: true, startAfter: true }), 202, 'backup'); + await operation(id, 'backup', since); + await waitForState(api, id, ['running'], { timeoutMs: START_TIMEOUT }); + since = socket.mark(); + assertStatus(await api('POST', `/servers/${id}/backups/${backupId}/restore`, { startAfter: true }), 202, 'restore'); + await operation(id, 'restore', since); + await waitForState(api, id, ['running'], { timeoutMs: START_TIMEOUT }); + await stop(id); + }); + await run.step('keeps the backup schedule within range', async () => { + const schedule = async (body) => { + const res = await api('POST', `/servers/${id}/backup-schedule`, body); + assert(res.status === 200 || res.status === 400, `${JSON.stringify(body)}: HTTP ${res.status}`); + return (await server(id)).backupSchedule; + }; + let s = await schedule({ enabled: true, intervalHours: 12, countdownMinutes: 3 }); + assert(s.enabled === true && s.intervalHours === 12 && s.countdownMinutes === 3, JSON.stringify(s)); + const res = await api('POST', `/servers/${id}/backup-schedule`, { enabled: true, intervalHours: 12, countdownMinutes: 3 }); + const next = Date.parse(res.body.nextBackupAt) - Date.now(); + assert(next > 11 * 3600_000 && next <= 12 * 3600_000, `next backup in ${Math.round(next / 60_000)} minutes`); + for (const bad of [{ intervalHours: 0 }, { intervalHours: 169 }, { intervalHours: -5 }, { countdownMinutes: 0 }, { countdownMinutes: 31 }]) { + s = await schedule(bad); + assert(s.intervalHours === 12 && s.countdownMinutes === 3, `${JSON.stringify(bad)} was stored: ${JSON.stringify(s)}`); + } + s = await schedule({ enabled: false }); + assert(s.enabled === false, 'not disabled'); + assert((await api('POST', `/servers/${id}/backup-schedule`, {})).body.nextBackupAt === null, 'a disabled schedule has a next backup'); + }); + await run.step('keeps retention within range and applies it', async () => { + const retention = async (body) => { + const res = await api('POST', `/servers/${id}/backup-retention`, body); + assert(res.status === 200 || res.status === 400, `${JSON.stringify(body)}: HTTP ${res.status}`); + return (await server(id)).backupSchedule; + }; + let s = await retention({ retentionCount: 2, retentionDays: 0 }); + assert(s.retentionCount === 2 && s.retentionDays === 0, JSON.stringify(s)); + for (const bad of [{ retentionCount: -1 }, { retentionCount: 101 }, { retentionDays: 366 }, { retentionDays: -1 }]) { + s = await retention(bad); + assert(s.retentionCount === 2 && s.retentionDays === 0, `${JSON.stringify(bad)} was stored: ${JSON.stringify(s)}`); + } + for (const name of ['CI retention 1', 'CI retention 2']) { + const since = socket.mark(); + assertStatus(await api('POST', `/servers/${id}/backups`, { name }), 202, name); + await operation(id, 'backup', since); + } + const names = (await backups(id)).map((b) => b.name).sort(); + assert(names.join() === 'CI retention 1,CI retention 2', `kept: ${names.join(', ')}`); + }); +} + +if (sections.includes('lifecycle')) await lifecycle(); +if (sections.includes('backups')) await backupsSection(); + +socket.close(); +run.finish(); From 55a6bcf581aa094d6ee3ab1c06fb399f1c1a643a Mon Sep 17 00:00:00 2001 From: Will Knowles <will.knowles@diamonddigital.dev> Date: Mon, 28 Sep 2026 22:47:29 +0100 Subject: [PATCH 08/13] ci: Check uploads, icons, plugins, downloads, export and import Nothing moved a file in or out of the panel in CI beyond one small .mrpack. The new transfers test covers: - uploads in one request (replacing a file, refusing a folder's name and a missing destination) and in chunks, sent last to first, with a repeated completion answered from the first, a wrong hash, a short chunk, an incomplete upload and a cancel each refused; - icons: a new server's default, an upload of any size resized to a 64x64 PNG that Minecraft can read, an undecodable image and a non-PNG refused, a chunked upload, delete and reset; - plugins on Paper and mods on Fabric: uploads (a non-jar refuses the request, a jar that isn't a zip is refused on its own), chunked uploads, listing, delete and delete-all, and client-only mods disabled on disk and re-enabled by uploading them again; - downloads of a plugin, all plugins, a file and the whole server, each with an exact Content-Length and reported over the WebSocket, with a missing file reported as failed; - a .cbx export with its backups and events, imported back both in one request and in chunks, keeping files, settings, group, backups and events under a new id with a port warning, and non-exports refused; - duplicating with and without the world, templates, and a server made from a .mrpack sent in chunks. lib.mjs gains a DGUP client, a PNG writer and reader, and raw request bodies for chunks. --- .github/workflows/ci.yml | 2 + test/ci/lib.mjs | 59 ++++++- test/ci/transfers.mjs | 328 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 388 insertions(+), 1 deletion(-) create mode 100644 test/ci/transfers.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 029511a..84b6c9a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,6 +121,8 @@ jobs: script: operations.mjs lifecycle - name: Backups script: operations.mjs backups + - name: Transfers + script: transfers.mjs steps: - name: Checkout uses: actions/checkout@v4 diff --git a/test/ci/lib.mjs b/test/ci/lib.mjs index e32defa..397384a 100644 --- a/test/ci/lib.mjs +++ b/test/ci/lib.mjs @@ -352,7 +352,7 @@ export function apiClient(key) { return async function api(method, path, body, { headers = {} } = {}) { const init = { method, headers: { ...headers } }; if (key) init.headers.authorization = `Bearer ${key}`; - if (body instanceof FormData) { + if (body instanceof FormData || body instanceof Uint8Array) { init.body = body; } else if (body !== undefined) { init.headers['content-type'] = 'application/json'; @@ -366,6 +366,33 @@ export function apiClient(key) { }; } +// Upload `data` as `filename` through an endpoint's chunked (DGUP) route at +// `base` (e.g. /servers/:id/files/upload), as the panel's dgup.js does, with +// `fields` in the complete request. Chunks go last to first, since the +// protocol lets them arrive in any order. Resolves to the complete response, +// plus `uploadId` and `chunks`. +export async function dgupUpload(api, base, filename, data, fields = {}) { + const init = await api('POST', `${base}/init`, { filename, totalSize: data.length }); + assertStatus(init, 200, `${base}/init`); + const { uploadId, chunkSize, totalChunks } = init.body; + for (let index = totalChunks - 1; index >= 0; index--) { + const chunk = data.subarray(index * chunkSize, (index + 1) * chunkSize); + const res = await api('POST', `${base}/chunk`, chunk, { headers: chunkHeaders(uploadId, index, chunk) }); + assertStatus(res, 200, `${base}/chunk ${index}`); + } + const complete = await api('POST', `${base}/complete`, { uploadId, ...fields }); + return Object.assign(complete, { uploadId, chunks: totalChunks }); +} + +export function chunkHeaders(uploadId, index, chunk, hash = crypto.createHash('sha256').update(chunk).digest('hex')) { + return { + 'content-type': 'application/octet-stream', + 'x-upload-id': uploadId, + 'x-chunk-index': String(index), + 'x-chunk-hash': hash + }; +} + // Poll GET /servers/:id until its state is one of `states`. A server that // disappears (a failed provision is auto-removed) or lands in `crashed` when // that wasn't asked for fails immediately rather than waiting out the clock. @@ -462,6 +489,36 @@ export function zipEntries(buf) { return names; } +// A solid-colour RGB PNG of the given size +export function makePng(width, height) { + const chunk = (type, data) => { + const body = Buffer.concat([Buffer.from(type), data]); + const out = Buffer.alloc(body.length + 8); + out.writeUInt32BE(data.length, 0); + body.copy(out, 4); + out.writeUInt32BE(zlib.crc32(body), body.length + 4); + return out; + }; + const header = Buffer.alloc(13); + header.writeUInt32BE(width, 0); + header.writeUInt32BE(height, 4); + header.writeUInt8(8, 8); // bit depth + header.writeUInt8(2, 9); // truecolour + const row = Buffer.concat([Buffer.from([0]), Buffer.alloc(width * 3, 0x4c)]); + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk('IHDR', header), + chunk('IDAT', zlib.deflateSync(Buffer.concat(Array(height).fill(row)))), + chunk('IEND', Buffer.alloc(0)) + ]); +} + +// A PNG's dimensions, read off its header +export function pngSize(buf) { + if (buf.readUInt32BE(0) !== 0x89504e47 || buf.toString('ascii', 12, 16) !== 'IHDR') throw new Error('not a PNG'); + return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) }; +} + // A zip of `entries` ({name: string | Buffer}), stored uncompressed: enough for // a test .mrpack without a zip dependency (zlib.crc32 needs Node 22.2+). export function makeZip(entries) { diff --git a/test/ci/transfers.mjs b/test/ci/transfers.mjs new file mode 100644 index 0000000..8eb84db --- /dev/null +++ b/test/ci/transfers.mjs @@ -0,0 +1,328 @@ +// Transfers test: everything that moves files in or out of the panel. +// Uploads in one request and in chunks (DGUP, docs/API.md "Chunked +// uploads"), server icons, plugins and mods, downloads and how they report +// over the WebSocket, .cbx export and import, duplicating a server, +// templates, and creating a server from a .mrpack sent in chunks. +// +// CRAFTBOX_URL=http://localhost:6464 node test/ci/transfers.mjs + +import crypto from 'node:crypto'; +import { + BASE_URL, waitForPanel, bootstrapPanel, apiClient, provisionServer, waitForState, withUpstream, upstreamFetch, + openSocket, dgupUpload, chunkHeaders, makeZip, makeModJar, makePng, pngSize, zipEntries, + createRunner, assert, assertStatus +} from './lib.mjs'; + +const PROVISION_TIMEOUT = 10 * 60_000; + +const run = createRunner('Transfers test'); +await waitForPanel(); +const { key, session } = await bootstrapPanel(); +const api = apiClient(key); +const socket = await openSocket({ cookie: session.cookie }); + +const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex'); +const readFile = async (id, path) => (await api('GET', `/servers/${id}/file?path=${encodeURIComponent(path)}`)).body?.file?.content; +const exists = async (id, path) => (await api('GET', `/servers/${id}/files?path=${encodeURIComponent(path.split('/').slice(0, -1).join('/'))}`)) + .body?.files?.some((f) => f.name === path.split('/').pop()) || false; +async function fetchBytes(path, headers = {}) { + const res = await fetch(BASE_URL + path, { headers: { authorization: `Bearer ${key}`, cookie: session.cookie, ...headers } }); + return { status: res.status, headers: res.headers, body: Buffer.from(await res.arrayBuffer()) }; +} +// Multipart, with the media type a browser would give each file's extension +function form(fields, files) { + const types = { png: 'image/png', jar: 'application/java-archive', txt: 'text/plain', zip: 'application/zip' }; + const f = new FormData(); + for (const [k, v] of Object.entries(fields)) f.append(k, v); + for (const [name, data, field = 'file'] of files) { + f.append(field, new Blob([data], { type: types[name.split('.').pop()] || 'application/octet-stream' }), name); + } + return f; +} +async function operation(id, name, since, timeoutMs = 180_000) { + const msg = await socket.waitFor((m) => m.type === 'operation' && m.serverId === id && m.operation === name && m.status !== 'progress', + { since, what: `the ${name} operation to finish`, timeoutMs }); + assert(msg.status === 'complete', `${name} ${msg.status}: ${msg.error || JSON.stringify(msg.payload)}`); + return msg; +} + +let vanilla = null; +let paper = null; +let fabric = null; +await run.step('creates vanilla, Paper and Fabric servers', async () => { + const base = { version: 'latest', memory: 1536, eula: true }; + vanilla = await provisionServer(api, { ...base, name: 'CI Transfers', serverType: 'vanilla', port: 25565, group: 'CI Moves' }, + { timeoutMs: PROVISION_TIMEOUT, label: 'vanilla' }); + paper = await provisionServer(api, { ...base, name: 'CI Plugins', serverType: 'paper', port: 25566 }, { timeoutMs: PROVISION_TIMEOUT, label: 'paper' }); + fabric = await provisionServer(api, { ...base, name: 'CI Mods', serverType: 'fabric', port: 25567 }, { timeoutMs: PROVISION_TIMEOUT, label: 'fabric' }); + for (const s of [vanilla, paper, fabric]) socket.send({ type: 'subscribe', serverId: s.id }); + assertStatus(await api('POST', `/servers/${vanilla.id}/statuspublic`, { enabled: true }), 200, 'statuspublic'); + assertStatus(await api('POST', `/servers/${vanilla.id}/advertisedip`, { value: 'play.example.test' }), 200, 'advertisedip'); +}); +if (!vanilla || !paper || !fabric) { + run.finish(); + process.exit(); +} +const v = `/servers/${vanilla.id}`; + +console.log('Uploads'); +const binary = crypto.randomBytes(300_000); +await run.step('uploads files in one request, replacing and refusing as documented', async () => { + assertStatus(await api('POST', `${v}/files/mkdir`, { name: 'ci-up' }), [200, 201], 'mkdir'); + assertStatus(await api('POST', `${v}/files/mkdir`, { path: 'ci-up', name: 'taken' }), [200, 201], 'mkdir taken'); + let res = await api('POST', `${v}/files/upload`, form({ path: 'ci-up' }, [['a.txt', 'first\n'], ['b.bin', binary]])); + assert(res.status === 200 && res.body.count === 2 && res.body.replaced === 0, `upload: ${res.status} ${res.text}`); + res = await api('POST', `${v}/files/upload`, form({ path: 'ci-up' }, [['a.txt', 'second\n'], ['taken', 'x']])); + assert(res.body.count === 1 && res.body.replaced === 1, `re-upload: ${res.text}`); + assert(res.body.rejected?.length === 1 && res.body.rejected[0].name === 'taken', `a folder's name wasn't refused: ${res.text}`); + assert(await readFile(vanilla.id, 'ci-up/a.txt') === 'second\n', 'a.txt not replaced'); + const down = await fetchBytes(`/api/v1${v}/download?path=ci-up/b.bin`); + assert(down.status === 200 && down.body.equals(binary), `binary round trip: HTTP ${down.status}, ${down.body.length} bytes`); + assert(Number(down.headers.get('content-length')) === binary.length, 'Content-Length doesn\'t match'); + assertStatus(await api('POST', `${v}/files/upload`, form({ path: 'no-such-dir' }, [['c.txt', 'x']])), 404, 'into a missing folder'); +}); +await run.step('uploads a file in chunks, in any order, and replays a lost completion', async () => { + const data = crypto.randomBytes(11 * 1024 * 1024 + 123); + const res = await dgupUpload(api, `${v}/files/upload`, 'big.bin', data, { path: 'ci-up' }); + assert(res.status === 200 && res.body.count === 1 && res.chunks > 2, `complete: ${res.status} ${res.text} (${res.chunks} chunks)`); + const again = await api('POST', `${v}/files/upload/complete`, { uploadId: res.uploadId }); + assert(again.status === res.status && again.text === res.text, `a repeated complete answered ${again.status} ${again.text}`); + const down = await fetchBytes(`/api/v1${v}/download?path=ci-up/big.bin`); + assert(sha256(down.body) === sha256(data), 'the assembled file differs'); +}); +await run.step('checks every chunk and refuses an incomplete upload', async () => { + const data = crypto.randomBytes(6 * 1024 * 1024); + const init = await api('POST', `${v}/files/upload/init`, { filename: 'checked.bin', totalSize: data.length }); + assertStatus(init, 200, 'init'); + const { uploadId, chunkSize } = init.body; + const first = data.subarray(0, chunkSize); + const bad = await api('POST', `${v}/files/upload/chunk`, first, { headers: chunkHeaders(uploadId, 0, first, 'a'.repeat(64)) }); + assert(bad.status === 400 && bad.body?.code === 'hash_mismatch', `wrong hash: ${bad.status} ${bad.text}`); + const short = first.subarray(1); + assertStatus(await api('POST', `${v}/files/upload/chunk`, short, { headers: chunkHeaders(uploadId, 0, short) }), 400, 'a short chunk'); + assertStatus(await api('POST', `${v}/files/upload/chunk`, first, { headers: chunkHeaders(uploadId, 0, first) }), 200, 'chunk 0'); + assertStatus(await api('POST', `${v}/files/upload/chunk`, first, { headers: chunkHeaders(uploadId, 0, first) }), 200, 'chunk 0 again'); + assertStatus(await api('POST', `${v}/files/upload/complete`, { uploadId }), 400, 'complete with a chunk missing'); + assertStatus(await api('POST', `${v}/files/upload/cancel`, { uploadId }), 200, 'cancel'); + assertStatus(await api('POST', `${v}/files/upload/complete`, { uploadId }), 404, 'complete after cancel'); + assert(!(await exists(vanilla.id, 'ci-up/checked.bin')), 'a cancelled upload landed'); +}); + +console.log('Icons'); +await run.step('resizes an uploaded icon to 64x64 and refuses anything else', async () => { + const icon = async () => { + const res = await fetchBytes(`/api/v1${v}/icon`); + return res.status === 200 ? { type: res.headers.get('content-type'), ...pngSize(res.body) } : { status: res.status }; + }; + const fresh = await icon(); + assert(fresh.width === 64 && fresh.height === 64, `a new server's icon: ${JSON.stringify(fresh)}`); + assertStatus(await api('POST', `${v}/icon`, form({}, [['wide.png', makePng(300, 120), 'icon']])), 200, 'upload'); + const up = await icon(); + assert(up.type === 'image/png' && up.width === 64 && up.height === 64, `after upload: ${JSON.stringify(up)}`); + assert(await exists(vanilla.id, 'server-icon.png'), 'no server-icon.png for Minecraft to read'); + const junk = await api('POST', `${v}/icon`, form({}, [['junk.png', crypto.randomBytes(2000), 'icon']])); + assert(junk.status === 400, `undecodable image: ${junk.status} ${junk.text}`); + assertStatus(await api('POST', `${v}/icon/upload/init`, { filename: 'icon.jpg', totalSize: 1000 }), 400, 'chunked .jpg'); + const chunked = await dgupUpload(api, `${v}/icon/upload`, 'tall.png', makePng(90, 400)); + assertStatus(chunked, 200, 'chunked upload'); + assertStatus(await api('DELETE', `${v}/icon`), 200, 'delete'); + assert((await icon()).status === 404, 'icon still served after delete'); + assertStatus(await api('POST', `${v}/icon/reset`), 200, 'reset'); + assert((await icon()).width === 64, 'no default icon after reset'); +}); + +console.log('Plugins and mods'); +const p = `/servers/${paper.id}`; +await run.step('manages plugins on a Paper server', async () => { + // Anything but a .jar refuses the whole request; a .jar that isn't a zip + // is refused on its own + assertStatus(await api('POST', `${p}/plugins/upload`, form({}, [['notes.txt', 'not a jar']])), 400, 'a .txt'); + const res = await api('POST', `${p}/plugins/upload`, form({}, [ + ['one.jar', makeModJar('paper', 'one')], ['two.jar', makeModJar('paper', 'two')], ['fake.jar', 'not a zip'] + ])); + assert(res.status === 200 && res.body.count === 2, `upload: ${res.status} ${res.text}`); + assert(res.body.rejected.map((r) => r.name).join() === 'fake.jar', `rejected: ${JSON.stringify(res.body.rejected)}`); + const chunked = await dgupUpload(api, `${p}/plugins/upload`, 'three.jar', makeModJar('paper', 'three')); + assert(chunked.status === 200 && chunked.body.count === 1, `chunked: ${chunked.text}`); + const list = (await api('GET', `${p}/plugins`)).body; + assert(list.contentType.label === 'Plugins' && list.files.map((f) => f.name).sort().join() === 'one.jar,three.jar,two.jar', + `listed: ${JSON.stringify(list)}`); + assert(list.files.every((f) => f.environment === 'both'), 'a plugin with an environment other than both'); + assertStatus(await api('GET', `${p}/plugins/environment`), 400, 'environments on a plugin server'); + assertStatus(await api('POST', `${p}/plugins/delete`, { filename: 'two.jar' }), 200, 'delete'); + assert((await api('GET', `${p}/plugins`)).body.files.length === 2, 'two.jar still listed'); + assertStatus(await api('GET', `/servers/${vanilla.id}/plugins`), 404, 'plugins on vanilla'); +}); +await run.step('reports plugin downloads over the WebSocket', async () => { + let since = socket.mark(); + const one = await fetchBytes(`${p}/plugins/download?file=one.jar&dl=ci-one`); + assert(one.status === 200 && one.body.equals(makeModJar('paper', 'one')), `one.jar: HTTP ${one.status}`); + let msg = await operation(paper.id, 'download', since); + assert(msg.payload?.token === 'ci-one' && msg.payload.bytes === one.body.length, JSON.stringify(msg.payload)); + since = socket.mark(); + const all = await fetchBytes(`${p}/plugins/download-all?dl=ci-all`); + assert(all.status === 200 && Number(all.headers.get('content-length')) === all.body.length, `download-all: HTTP ${all.status}`); + assert(zipEntries(all.body).map((n) => n.split('/').pop()).sort().join() === 'one.jar,three.jar', `zip: ${zipEntries(all.body).join(', ')}`); + msg = await operation(paper.id, 'download', since); + assert(msg.payload?.token === 'ci-all', JSON.stringify(msg.payload)); + since = socket.mark(); + assertStatus(await fetchBytes(`${p}/plugins/download?file=missing.jar&dl=ci-missing`), 404, 'a missing plugin'); + // A failure carries its reason as a string, naming the file but not the + // token (public/js/download.js allows for that) + msg = await socket.waitFor((m) => m.type === 'operation' && m.operation === 'download' && m.status === 'failed', { since, what: 'the failed download' }); + assert(/missing\.jar/.test(msg.error), JSON.stringify(msg)); + assertStatus(await api('POST', `${p}/plugins/delete-all`), 200, 'delete-all'); + assert((await api('GET', `${p}/plugins`)).body.files.length === 0, 'plugins left after delete-all'); +}); +await run.step('tags mods client-only or server-only on a Fabric server', async () => { + const f = `/servers/${fabric.id}`; + assertStatus(await api('POST', `${f}/plugins/upload`, form({}, [['mod.jar', makeModJar('fabric', 'ci_mod')]])), 200, 'upload'); + assertStatus(await api('POST', `${f}/plugins/environment`, { filename: 'mod.jar', environment: 'client' }), 200, 'client'); + let list = (await api('GET', `${f}/plugins`)).body; + assert(list.contentType.label === 'Mods' && list.files[0]?.environment === 'client', `listed: ${JSON.stringify(list.files)}`); + assert(await exists(fabric.id, 'mods/mod.jar.disabled') && !(await exists(fabric.id, 'mods/mod.jar')), 'a client-only mod left enabled on disk'); + assert((await api('GET', `${f}/plugins/environment`)).body.environment['mod.jar'] === 'client', 'environment map'); + assertStatus(await api('POST', `${f}/plugins/environment`, { filename: 'mod.jar', environment: 'sideways' }), 400, 'a bad environment'); + // Uploading it again is "put this on the server": enabled, both sides + assertStatus(await api('POST', `${f}/plugins/upload`, form({}, [['mod.jar', makeModJar('fabric', 'ci_mod')]])), 200, 're-upload'); + list = (await api('GET', `${f}/plugins`)).body; + assert(list.files.length === 1 && list.files[0].environment === 'both', `after re-upload: ${JSON.stringify(list.files)}`); + assert(await exists(fabric.id, 'mods/mod.jar') && !(await exists(fabric.id, 'mods/mod.jar.disabled')), 'still disabled on disk'); +}); + +console.log('Downloads'); +await run.step('downloads a file and the whole server, reporting each', async () => { + let since = socket.mark(); + const file = await fetchBytes(`${v}/download?path=ci-up/a.txt&dl=ci-file`); + assert(file.status === 200 && file.body.toString() === 'second\n', `file: HTTP ${file.status}`); + assert((await operation(vanilla.id, 'download', since)).payload?.token === 'ci-file', 'no report for the file'); + since = socket.mark(); + const zip = await fetchBytes(`${v}/download-zip?dl=ci-zip`); + assert(zip.status === 200 && Number(zip.headers.get('content-length')) === zip.body.length, `zip: HTTP ${zip.status}`); + const names = zipEntries(zip.body); + assert(names.includes('ci-up/a.txt') && names.includes('server.properties'), `zip holds ${names.slice(0, 8).join(', ')}…`); + assert((await operation(vanilla.id, 'download', since)).payload?.token === 'ci-zip', 'no report for the zip'); + assertStatus(await fetchBytes(`${v}/download?path=../../craftbox.sqlite`), 403, 'traversal'); +}); + +console.log('Export and import'); +let archive = null; +await run.step('exports a server, its backups and events as .cbx', async () => { + assertStatus(await api('POST', `${v}/edit-file`, { filePath: 'ci-cbx.txt', content: 'travels\n' }), 200, 'seed file'); + const since = socket.mark(); + assertStatus(await api('POST', `${v}/backups`, { name: 'CI export' }), 202, 'backup'); + await operation(vanilla.id, 'backup', since); + const res = await fetchBytes(`/api/v1${v}/export?backups=true&events=true`); + assert(res.status === 200, `export: HTTP ${res.status} ${res.body.toString().slice(0, 200)}`); + assert(res.headers.get('content-type') === 'application/x-craftbox-export+zip', `type ${res.headers.get('content-type')}`); + assert(/filename="?CI_Transfers\.cbx"?/.test(res.headers.get('content-disposition') || ''), `named ${res.headers.get('content-disposition')}`); + assert(Number(res.headers.get('content-length')) === res.body.length, 'Content-Length doesn\'t match'); + const names = zipEntries(res.body); + const missing = ['craftbox-manifest.json', 'modenv.json', 'server/ci-cbx.txt', 'server/server.properties', 'backups.json', 'events.json'] + .filter((n) => !names.includes(n)); + assert(missing.length === 0 && names.some((n) => /^backups\/.+\.zip$/.test(n)), `missing ${missing.join(', ')} (has ${names.length} entries)`); + archive = res.body; +}); +async function checkImported(res, how) { + assert(res.status === 201, `${how}: ${res.status} ${res.text}`); + const id = res.body.server.id; + assert(id !== vanilla.id, 'imported over the original\'s id'); + assert(res.body.warnings?.some((w) => /port/i.test(w)), `no port-collision warning: ${JSON.stringify(res.body.warnings)}`); + await waitForState(api, id, ['stopped'], { timeoutMs: 120_000, label: how }); + const s = (await api('GET', `/servers/${id}`)).body.server; + const kept = { name: s.name, memory: s.memory, group: s.group, statusPagePublic: s.statusPagePublic, advertisedIp: s.advertisedIp }; + assert(JSON.stringify(kept) === JSON.stringify({ name: 'CI Transfers', memory: 1536, group: 'CI Moves', statusPagePublic: true, advertisedIp: 'play.example.test' }), + `settings: ${JSON.stringify(kept)}`); + assert(await readFile(id, 'ci-cbx.txt') === 'travels\n', 'files not imported'); + const backups = (await api('GET', `/servers/${id}/backups`)).body.backups; + assert(backups.length === 1 && backups[0].name === 'CI export', `backups: ${backups.map((b) => b.name).join(', ')}`); + assert((await api('GET', `/servers/${id}/events?limit=200`)).body.events.length > 0, 'no events imported'); + return id; +} +if (archive) { + await run.step('imports it back as a copy', async () => { + await checkImported(await api('POST', '/servers/import', form({}, [['CI_Transfers.cbx', archive, 'archive']])), 'import'); + }); + await run.step('imports it through a chunked upload', async () => { + await checkImported(await dgupUpload(api, '/servers/import/upload', 'CI_Transfers.cbx', archive), 'chunked import'); + }); + await run.step('refuses archives that aren\'t Craftbox exports', async () => { + const cases = [ + ['a zip without a manifest', 'plain.cbx', makeZip({ 'server/server.properties': 'motd=x\n' })], + ['not a zip', 'noise.cbx', crypto.randomBytes(4096)], + ['the wrong extension', 'CI_Transfers.zip', archive] + ]; + for (const [what, name, data] of cases) { + assertStatus(await api('POST', '/servers/import', form({}, [[name, data, 'archive']])), 400, what); + } + }); +} + +console.log('Copies'); +await run.step('duplicates a server, with and without its world', async () => { + assertStatus(await api('POST', `${v}/files/mkdir`, { name: 'world' }), [200, 201], 'world'); + assertStatus(await api('POST', `${v}/edit-file`, { filePath: 'world/ci.txt', content: 'a world\n' }), 200, 'world file'); + const copy = async (name, port, includeWorld) => { + const since = socket.mark(); + const res = await api('POST', `${v}/duplicate`, { name, port, includeWorld }); + assertStatus(res, 201, `duplicate ${name}`); + const id = res.body.server.id; + socket.send({ type: 'subscribe', serverId: id }); + await operation(id, 'duplicate', since); + await waitForState(api, id, ['stopped'], { timeoutMs: 120_000, label: name }); + return id; + }; + const bare = await copy('CI Copy', 25570, false); + assert(await readFile(bare, 'ci-cbx.txt') === 'travels\n', 'files not copied'); + assert(!(await exists(bare, 'world/ci.txt')), 'the world came along without includeWorld'); + assert((await api('GET', `/servers/${bare}`)).body.server.port === 25570, 'port not set'); + const whole = await copy('CI Copy World', 25571, true); + assert(await readFile(whole, 'world/ci.txt') === 'a world\n', 'the world was left behind'); + assertStatus(await api('POST', `${v}/duplicate`, { name: 'bad/name', port: 25572 }), 400, 'a bad name'); +}); +await run.step('saves a template from a server and deletes it', async () => { + assertStatus(await api('POST', '/templates', { serverId: vanilla.id, name: 'bad/name' }), 400, 'a bad name'); + const res = await api('POST', '/templates', { serverId: vanilla.id, name: 'CI Template' }); + assertStatus(res, 201, 'template'); + const t = res.body.template; + const want = { serverType: 'vanilla', version: vanilla.version, memory: 1536, port: 25565 }; + const got = Object.fromEntries(Object.keys(want).map((k) => [k, t[k]])); + assert(JSON.stringify(got) === JSON.stringify(want) && t.levelType, `template: ${JSON.stringify(t)}`); + assert(!('group' in t) || !t.group, 'a template carried the dashboard group'); + assert((await api('GET', '/templates')).body.templates.some((x) => x.id === t.id), 'not listed'); + assertStatus(await api('GET', `/templates/${t.id}`), 200, 'get'); + assertStatus(await api('DELETE', `/templates/${t.id}`), [200, 204], 'delete'); + assertStatus(await api('GET', `/templates/${t.id}`), 404, 'deleted template'); +}); + +console.log('Modpacks'); +await run.step('creates a server from a .mrpack sent in chunks', async () => { + const mc = (await api('GET', '/versions?type=fabric')).body?.latest; + const loader = (await (await upstreamFetch('https://meta.fabricmc.net/v2/versions/loader')).json()).find((l) => l.stable)?.version; + assert(mc && loader, `Fabric ${mc}, loader ${loader}`); + const pack = makeZip({ + 'modrinth.index.json': JSON.stringify({ + formatVersion: 1, game: 'minecraft', versionId: '1.0.0', name: 'CI Chunked Pack', files: [], + dependencies: { minecraft: mc, 'fabric-loader': loader } + }), + 'overrides/config/ci.txt': 'from the pack', + 'overrides/mods/pack-mod.jar': makeModJar('fabric', 'pack_mod') + }); + const server = await withUpstream(['fabric', 'mojang'], async () => { + const res = await dgupUpload(api, '/servers/from-mrpack/upload', 'ci.mrpack', pack, + { name: 'CI Chunked Pack', port: '25575', memory: '2048', eula: 'true', levelType: '' }); + assert(res.status === 201, `complete: ${res.status} ${res.text}`); + try { + return await waitForState(api, res.body.server.id, ['stopped'], { timeoutMs: PROVISION_TIMEOUT, label: 'mrpack' }); + } catch (err) { + await api('DELETE', `/servers/${res.body.server.id}`); + throw err; + } + }); + assert(server.serverType === 'fabric' && server.version === mc && server.modpack?.source === 'file', `record: ${JSON.stringify(server.modpack)}`); + assert(await readFile(server.id, 'config/ci.txt') === 'from the pack', 'overrides not applied'); + assert((await api('GET', `/servers/${server.id}/plugins`)).body.files.some((f) => f.name === 'pack-mod.jar'), 'the pack\'s mod isn\'t listed'); +}); + +socket.close(); +run.finish(); From 7eff71c52b63fda5a79454a31db2d9735726b6ca Mon Sep 17 00:00:00 2001 From: Will Knowles <will.knowles@diamonddigital.dev> Date: Mon, 28 Sep 2026 22:51:13 +0100 Subject: [PATCH 09/13] ci: Check an upgrade from the published release Updating a container is the one path every user takes, and nothing tested that data written by the last release survives the new one. The upgrade test starts the published image on a fresh volume and fills it through that version's own API: a server with its settings, MOTD, properties, a file, a group and colour, a backup schedule, a backup and a template. Only what that version offers is seeded and checked, since groups arrived in 1.1.0. It then plants the records the startup migrations exist for (a server stuck provisioning, a failed provision, a NeoForge template labelled "1.26.1" as before 1.2.1) and kills the container with the server running. The image under test must come up on the same volume with the admin, the API key and the open session still valid, everything seeded intact, the running server reset to stopped, the stuck provision marked crashed, the failed one purged with its directory, the template relabelled to 26.1.2, and the server able to start. PRs upgrade from `latest`; the weekly run adds older releases. The test runs its own containers, and keeps both for their logs when it fails. --- .github/workflows/ci.yml | 42 ++++++++ test/ci/upgrade.mjs | 211 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 test/ci/upgrade.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 84b6c9a..b45d2bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -153,6 +153,48 @@ jobs: if: failure() run: docker logs craftbox + # The published release, filled with data, replaced by this image on the + # same volume. ci-extended.yml goes back further. + upgrade: + name: Upgrade from ${{ matrix.from }} + needs: build + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + from: [latest] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Download image + uses: actions/download-artifact@v4 + with: + name: craftbox-image + path: ${{ runner.temp }} + + - name: Load image + run: docker load --input "$RUNNER_TEMP/craftbox-image.tar" + + - name: Upgrade from willtda/craftbox:${{ matrix.from }} + run: node test/ci/upgrade.mjs + env: + CRAFTBOX_URL: http://localhost:6464 + CRAFTBOX_UPGRADE_FROM: willtda/craftbox:${{ matrix.from }} + CRAFTBOX_IMAGE: craftbox:ci + + - name: Craftbox logs + if: failure() + run: | + echo "::group::Before the upgrade"; docker logs craftbox-old || true; echo "::endgroup::" + echo "::group::After the upgrade"; docker logs craftbox || true; echo "::endgroup::" + servers: name: Start ${{ matrix.type }} (latest) needs: build diff --git a/test/ci/upgrade.mjs b/test/ci/upgrade.mjs new file mode 100644 index 0000000..6a80dd5 --- /dev/null +++ b/test/ci/upgrade.mjs @@ -0,0 +1,211 @@ +// Upgrade test: a published Craftbox with data in it, replaced by the image +// under test on the same volume, as a user updating their container would. +// +// It seeds the old version through its own API (only what that version +// offers: groups arrived in 1.1.0), plants the records the startup +// migrations exist for (a server stuck provisioning, a failed provision, a +// NeoForge template labelled "1.26.x" as before 1.2.1), and kills the old +// container with a server running. The new one must come up with everything +// intact, the stale states reset, the migrations applied, the admin, API key +// and even the open session still valid, and the server able to start. +// +// CRAFTBOX_UPGRADE_FROM=willtda/craftbox:latest CRAFTBOX_IMAGE=craftbox:ci node test/ci/upgrade.mjs +// +// It runs the containers itself, publishing CRAFTBOX_URL's port (default +// 6464), with names craftbox-old and craftbox, and a fresh volume. + +import crypto from 'node:crypto'; +import { + BASE_URL, CI_USER, UpstreamError, waitForPanel, bootstrapPanel, createSession, apiClient, provisionServer, + waitForState, withUpstream, docker, createRunner, assert, assertStatus, sleep +} from './lib.mjs'; + +const FROM = process.env.CRAFTBOX_UPGRADE_FROM || 'willtda/craftbox:latest'; +const TO = process.env.CRAFTBOX_IMAGE || 'craftbox:ci'; +const PORT = new URL(BASE_URL).port || '80'; +const OLD = 'craftbox-old'; +const NEW = 'craftbox'; +const VOLUME = `craftbox-upgrade-${crypto.randomBytes(4).toString('hex')}`; +// Every release so far can run it, with the Java 21 all their images carry +const MC_VERSION = '1.21.1'; + +const run = createRunner(`Upgrade test (${FROM} → ${TO})`); + +function startContainer(name, image) { + try { docker(['rm', '-f', name]); } catch { /* not there */ } + docker(['run', '-d', '--name', name, '-p', `${PORT}:6464`, '-v', `${VOLUME}:/app/data`, image]); +} + +// Write straight into a running panel's database, as its own user +function plantRecords(container, records) { + const script = ` + const db = new (require('better-sqlite3'))('data/craftbox.sqlite'); + const records = JSON.parse(process.argv[1]); + for (const [table, key, value] of records) { + db.prepare('INSERT OR REPLACE INTO ' + table + ' (ID, json) VALUES (?, ?)').run(key, JSON.stringify(value)); + } + console.log(records.length);`; + return docker(['exec', '-u', 'craftbox', '-w', '/app', container, 'node', '-e', script, JSON.stringify(records)]).trim(); +} + +const seeded = {}; +let key = null; +let oldSession = null; +let server = null; + +await run.step(`starts ${FROM} on a fresh volume`, async () => { + let lastErr = null; + for (let attempt = 1; attempt <= 3; attempt++) { + try { + docker(['pull', FROM]); + lastErr = null; + break; + } catch (err) { + lastErr = err; + await sleep(15_000); + } + } + if (lastErr) throw new UpstreamError('Docker Hub', `pulling ${FROM}: ${lastErr.stderr || lastErr.message}`); + docker(['volume', 'create', VOLUME]); + startContainer(OLD, FROM); + await waitForPanel(); + seeded.version = (await (await fetch(`${BASE_URL}/login`)).text()).match(/v(\d+\.\d+\.\d+[^<\s"]*)/)?.[1] || 'unknown'; + console.log(` running ${seeded.version}`); +}); + +await run.step('fills it with a server, settings, files, a backup and a template', async () => { + const boot = await bootstrapPanel(); + key = boot.key; + oldSession = boot.session; + const api = apiClient(key); + server = await provisionServer(api, { + name: 'CI Upgrade', serverType: 'vanilla', version: MC_VERSION, port: 25565, memory: 1536, eula: true + }, { timeoutMs: 10 * 60_000, label: 'vanilla' }); + const id = server.id; + const ok = async (what, method, path, body) => { + const res = await api(method, path, body); + if (res.status >= 200 && res.status < 300) seeded[what] = true; + return res; + }; + assertStatus(await ok('autoRestart', 'POST', `/servers/${id}/autorestart`, { enabled: true }), 200, 'autorestart'); + assertStatus(await ok('statusPagePublic', 'POST', `/servers/${id}/statuspublic`, { enabled: true }), 200, 'statuspublic'); + assertStatus(await ok('motd', 'POST', `/servers/${id}/motd`, { motd: 'Carried over' }), 200, 'motd'); + assertStatus(await ok('properties', 'POST', `/servers/${id}/properties`, { 'max-players': 7 }), 200, 'properties'); + assertStatus(await ok('file', 'POST', `/servers/${id}/edit-file`, { filePath: 'ci-upgrade.txt', content: 'from the old version\n' }), 200, 'file'); + await ok('advertisedIp', 'POST', `/servers/${id}/advertisedip`, { value: 'upgrade.example.test' }); + if ((await ok('group', 'POST', `/servers/${id}/group`, { group: 'CI Upgrade Group' })).status === 200) { + await ok('groupColor', 'POST', `/groups/${encodeURIComponent('CI Upgrade Group')}`, { color: '#123456' }); + } + await ok('schedule', 'POST', `/servers/${id}/backup-schedule`, { enabled: true, intervalHours: 24, countdownMinutes: 5 }); + + assertStatus(await api('POST', `/servers/${id}/backups`, { name: 'CI old backup' }), 202, 'backup'); + const deadline = Date.now() + 120_000; + while (!(await api('GET', `/servers/${id}/backups`)).body?.backups?.length) { + assert(Date.now() < deadline, 'backup never listed'); + await sleep(1000); + } + await waitForState(api, id, ['stopped'], { timeoutMs: 60_000 }); + const template = await api('POST', '/templates', { serverId: id, name: 'CI Old Template' }); + assertStatus(template, 201, 'template'); + seeded.template = template.body.template; + console.log(` seeded: ${Object.keys(seeded).filter((k) => seeded[k] === true).join(', ')}`); +}); + +const planted = { provisioning: crypto.randomUUID(), failed: crypto.randomUUID(), neoTemplate: crypto.randomUUID() }; +await run.step('plants the records the startup migrations are for, then kills it mid-run', async () => { + const api = apiClient(key); + const record = (await api('GET', `/servers/${server.id}`)).body.server; + const copy = (id, extra) => ({ ...record, id, directory: `/app/data/servers/${id}`, group: null, backupSchedule: null, ...extra }); + for (const id of [planted.provisioning, planted.failed]) { + docker(['exec', '-u', 'craftbox', OLD, 'mkdir', '-p', `/app/data/servers/${id}`]); + } + const count = plantRecords(OLD, [ + ['servers', `server_${planted.provisioning}`, copy(planted.provisioning, { name: 'CI Interrupted', state: 'provisioning', port: 25580 })], + ['servers', `server_${planted.failed}`, copy(planted.failed, { + name: 'CI Failed', state: 'crashed', crashReason: 'Provisioning failed: CI', provisionFailed: true, port: 25581 + })], + ['templates', `template_${planted.neoTemplate}`, { + ...seeded.template, id: planted.neoTemplate, name: 'CI NeoForge', serverType: 'neoforge', version: '1.26.1', build: '26.1.2.112' + }] + ]); + assert(count === '3', `planted ${count}`); + await withUpstream(['mojang'], async () => { + assertStatus(await api('POST', `/servers/${server.id}/start`), 200, 'start'); + await waitForState(api, server.id, ['running'], { timeoutMs: 5 * 60_000 }); + }); + // No graceful shutdown: the database still says running + docker(['kill', OLD]); +}); + +await run.step(`starts ${TO} on the same volume`, async () => { + startContainer(NEW, TO); + await waitForPanel(); + const logs = docker(['logs', NEW]); + assert(!/Fatal startup error/.test(logs), `startup failed:\n${logs.slice(-2000)}`); +}); + +const api = apiClient(key); +await run.step('keeps the admin, the API key and the open session', async () => { + assertStatus(await api('GET', '/servers'), 200, 'the old API key'); + assert((await oldSession.get('/dashboard')).status === 200, 'the session open before the upgrade was signed out'); + const fresh = createSession(); + assert((await fresh.get('/setup')).location === '/dashboard', 'setup offered again'); + await fresh.get('/login'); + const res = await fresh.post('/login', CI_USER); + assert(res.status === 302 && res.location === '/dashboard', `sign-in: ${res.status} → ${res.location}`); +}); +await run.step('keeps the server, its settings and files', async () => { + const s = (await api('GET', `/servers/${server.id}`)).body?.server; + assert(s, 'the server is gone'); + const want = { name: 'CI Upgrade', version: MC_VERSION, port: 25565, memory: 1536, serverType: 'vanilla' }; + if (seeded.autoRestart) want.autoRestart = true; + if (seeded.statusPagePublic) want.statusPagePublic = true; + if (seeded.advertisedIp) want.advertisedIp = 'upgrade.example.test'; + if (seeded.group) want.group = 'CI Upgrade Group'; + const got = Object.fromEntries(Object.keys(want).map((k) => [k, s[k]])); + assert(JSON.stringify(got) === JSON.stringify(want), `record: ${JSON.stringify(got)}`); + assert(s.state === 'stopped', `left "${s.state}" after the old panel was killed running`); + const props = (await api('GET', `/servers/${server.id}/file?path=server.properties`)).body.file.content; + assert(/^max-players=7$/m.test(props) && /^motd=Carried over$/m.test(props), 'server.properties changed'); + assert((await api('GET', `/servers/${server.id}/file?path=ci-upgrade.txt`)).body?.file?.content === 'from the old version\n', 'file lost'); + if (seeded.groupColor) { + const group = (await api('GET', '/groups')).body.groups.find((g) => g.name === 'CI Upgrade Group'); + assert(group?.color === '#123456', `group colour ${group?.color}`); + } + if (seeded.schedule) assert(s.backupSchedule?.enabled === true, 'backup schedule lost'); +}); +await run.step('keeps its backups, events and templates', async () => { + const backups = (await api('GET', `/servers/${server.id}/backups`)).body.backups; + assert(backups.length === 1 && backups[0].name === 'CI old backup', `backups: ${JSON.stringify(backups.map((b) => b.name))}`); + const zip = await fetch(`${BASE_URL}/api/v1/servers/${server.id}/backups/${backups[0].id}/download`, { headers: { authorization: `Bearer ${key}` } }); + assert(zip.status === 200 && (await zip.arrayBuffer()).byteLength === backups[0].size, `backup download: HTTP ${zip.status}`); + const events = (await api('GET', `/servers/${server.id}/events?limit=200`)).body.events.map((e) => e.type); + assert(events.includes('backup_create') && events.includes('started'), `events: ${events.join(', ')}`); + const t = (await api('GET', `/templates/${seeded.template.id}`)).body?.template; + assert(t?.name === 'CI Old Template' && t.version === MC_VERSION && t.memory === 1536, `template: ${JSON.stringify(t)}`); +}); +await run.step('runs the startup migrations', async () => { + const interrupted = (await api('GET', `/servers/${planted.provisioning}`)).body?.server; + assert(interrupted?.state === 'crashed' && interrupted.crashReason === 'Provisioning interrupted by restart', + `interrupted provision: ${interrupted?.state} (${interrupted?.crashReason})`); + assertStatus(await api('GET', `/servers/${planted.failed}`), 404, 'failed provision not purged'); + const dir = docker(['exec', NEW, 'sh', '-c', `test -e /app/data/servers/${planted.failed} && echo left || echo gone`]).trim(); + assert(dir === 'gone', 'the failed provision\'s directory was left behind'); + const neo = (await api('GET', `/templates/${planted.neoTemplate}`)).body?.template; + assert(neo?.version === '26.1.2', `NeoForge template relabelled to ${neo?.version}`); +}); +await run.step('starts the server on the new version', async () => { + await withUpstream(['mojang'], async () => { + assertStatus(await api('POST', `/servers/${server.id}/start`), 200, 'start'); + await waitForState(api, server.id, ['running'], { timeoutMs: 5 * 60_000 }); + }); + assertStatus(await api('POST', `/servers/${server.id}/stop`), 200, 'stop'); + await waitForState(api, server.id, ['stopped'], { timeoutMs: 120_000 }); +}); + +// Keep the containers for their logs if anything failed; CI throws them away +if (run.failed === 0 && !process.env.GITHUB_ACTIONS) { + for (const name of [OLD, NEW]) docker(['rm', '-f', name]); + docker(['volume', 'rm', VOLUME]); +} +run.finish(); From d8099a80f73f50e57bdbb09943ba7de1207748e7 Mon Sep 17 00:00:00 2001 From: Will Knowles <will.knowles@diamonddigital.dev> Date: Mon, 28 Sep 2026 23:14:02 +0100 Subject: [PATCH 10/13] ci: Let the checks share a panel, and run more of them outside Docker The WebSocket, transfers, operations and browser tests each insisted on a fresh panel to run the setup wizard on. They now sign in to one that's already set up (openPanel() in lib.mjs), so several can run in turn against one panel, as they will against Craftbox run outside Docker. The lifecycle section's restore-point Settings save needs nothing from the container, so it now runs everywhere instead of being skipped with the checks that do, and the skipped checks carry their real names. Waiting for a WebSocket message left its timeout running after the message arrived, so a script could sit idle for up to three minutes before exiting. The timeout is now cleared. --- test/ci/browser.mjs | 4 ++-- test/ci/lib.mjs | 12 +++++++++-- test/ci/operations.mjs | 48 +++++++++++++++++++++++------------------- test/ci/transfers.mjs | 4 ++-- test/ci/websocket.mjs | 4 ++-- 5 files changed, 42 insertions(+), 30 deletions(-) diff --git a/test/ci/browser.mjs b/test/ci/browser.mjs index 0c4bd81..09ec24b 100644 --- a/test/ci/browser.mjs +++ b/test/ci/browser.mjs @@ -10,7 +10,7 @@ // Chrome is found on PATH or at its usual install path; CHROME_PATH overrides. import { - waitForPanel, bootstrapPanel, apiClient, provisionServer, waitForState, withUpstream, panelPages, + waitForPanel, openPanel, apiClient, provisionServer, waitForState, withUpstream, panelPages, CI_USER, createRunner, assert, assertStatus } from './lib.mjs'; import { launchChrome } from './chrome.mjs'; @@ -21,7 +21,7 @@ const UUID = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'; const run = createRunner('Browser test'); await waitForPanel(); -const { key } = await bootstrapPanel(); +const { key } = await openPanel(); const api = apiClient(key); const browser = await launchChrome(); const page = await browser.newPage(); diff --git a/test/ci/lib.mjs b/test/ci/lib.mjs index 397384a..3ddaeac 100644 --- a/test/ci/lib.mjs +++ b/test/ci/lib.mjs @@ -244,6 +244,13 @@ export async function loginPanel({ username, password } = CI_USER) { return { key: await mintApiKey(session), session, page: (path) => pageOf(session, path) }; } +// Set up a fresh panel, or sign in to one another script already set up +export async function openPanel(credentials = CI_USER) { + const res = await fetch(`${BASE_URL}/setup`, { redirect: 'manual' }); + await res.arrayBuffer(); + return res.status === 200 ? bootstrapPanel(credentials) : loginPanel(credentials); +} + async function pageOf(session, path) { const res = await session.get(path); return { status: res.status, html: res.text }; @@ -302,6 +309,7 @@ export async function openSocket({ cookie, path = '/' } = {}) { for (const waiter of waiters) { if (waiter.match(msg)) { waiters.delete(waiter); + clearTimeout(waiter.timer); waiter.resolve(msg); } } @@ -320,10 +328,10 @@ export async function openSocket({ cookie, path = '/' } = {}) { if (found) return Promise.resolve(found); return new Promise((resolve, reject) => { const waiter = { match, resolve }; - waiters.add(waiter); - setTimeout(() => { + waiter.timer = setTimeout(() => { if (waiters.delete(waiter)) reject(new Error(`no ${what} within ${timeoutMs / 1000}s`)); }, timeoutMs); + waiters.add(waiter); }); }, close: () => ws.close() diff --git a/test/ci/operations.mjs b/test/ci/operations.mjs index aad7ac5..287d451 100644 --- a/test/ci/operations.mjs +++ b/test/ci/operations.mjs @@ -16,7 +16,7 @@ // crash and auto-start ones) are skipped. import { - CONTAINER, waitForPanel, bootstrapPanel, apiClient, provisionServer, waitForState, withUpstream, + CONTAINER, waitForPanel, openPanel, apiClient, provisionServer, waitForState, withUpstream, transientError, openSocket, docker, zipEntries, BASE_URL, createRunner, assert, assertStatus, sleep } from './lib.mjs'; @@ -26,7 +26,7 @@ const sections = process.argv.slice(2).length ? process.argv.slice(2) : ['lifecy const run = createRunner(`Operations test (${sections.join(', ')})`); await waitForPanel(); -const { key, session } = await bootstrapPanel(); +const { key, session } = await openPanel(); const api = apiClient(key); let socket = await openSocket({ cookie: session.cookie }); @@ -152,15 +152,31 @@ async function lifecycle() { assert(s.state === 'stopped' && !s.crashReason, `a kill left it ${s.state} (${s.crashReason})`); }); + await run.step('saves settings behind a restore point while running, and restarts', async () => { + await start(id); + const since = socket.mark(); + const res = await api('POST', `/servers/${id}/edit`, { name: 'CI Lifecycle', port: 25565, memory: 1536, difficulty: 'hard', backup: true }); + assertStatus(res, 202, 'edit with backup'); + await operation(id, 'backup', since); + const saved = await operation(id, 'settings-save', since); + assert(saved.payload?.restarted === true, `payload ${JSON.stringify(saved.payload)}`); + await waitForState(api, id, ['running'], { timeoutMs: START_TIMEOUT }); + assert(/^difficulty=hard$/m.test(await readFile(id, 'server.properties')), 'difficulty not saved'); + assert((await backups(id)).some((b) => b.name === 'Pre-edit backup'), 'no Pre-edit backup'); + }); + + // The rest reach into the container + const crash = 'detects a crash, and keeps its details off the status page'; + const autoRestart = 'restarts itself after a crash when auto-restart is on'; + const signalled = 'detects a JVM killed by a signal (as the kernel\'s OOM killer does)'; + const autoStart = 'auto-starts across a panel restart only when asked to'; if (!CONTAINER) { - for (const name of ['detects a crash', 'restarts itself after a crash', 'detects a JVM killed by a signal', 'restores settings behind a backup while running', 'auto-starts across a panel restart']) { - run.skip(name, 'CRAFTBOX_CONTAINER not set'); - } + for (const name of [crash, autoRestart, signalled, autoStart]) run.skip(name, 'CRAFTBOX_CONTAINER not set'); return; } - await run.step('detects a crash, and keeps its details off the status page', async () => { - await start(id); + await run.step(crash, async () => { + if ((await server(id)).state !== 'running') await start(id); const since = socket.mark(); signalJava('TERM'); // the JVM exits 143 const s = await waitForState(api, id, ['crashed', 'stopped'], { timeoutMs: 60_000 }); @@ -170,7 +186,7 @@ async function lifecycle() { const pub = (await (await fetch(`${BASE_URL}/status/${id}/api`)).json()).server; assert(pub.state === 'crashed' && !('crashReason' in pub) && !('exitCode' in pub), `public: ${JSON.stringify(pub)}`); }); - await run.step('restarts itself after a crash when auto-restart is on', async () => { + await run.step(autoRestart, async () => { assertStatus(await api('POST', `/servers/${id}/autorestart`, { enabled: true }), 200, 'autorestart'); await start(id); const since = socket.mark(); @@ -178,7 +194,7 @@ async function lifecycle() { await socket.waitFor((m) => m.type === 'state' && m.serverId === id && m.state === 'crashed', { since, what: 'the crash' }); await socket.waitFor((m) => m.type === 'state' && m.serverId === id && m.state === 'running', { since, what: 'running again', timeoutMs: START_TIMEOUT }); }); - await run.step('detects a JVM killed by a signal (as the kernel\'s OOM killer does)', async () => { + await run.step(signalled, async () => { const since = socket.mark(); signalJava('KILL'); await socket.waitFor((m) => m.type === 'state' && m.serverId === id && ['crashed', 'stopped'].includes(m.state), { since, what: 'the exit' }); @@ -187,19 +203,7 @@ async function lifecycle() { `recorded as a clean stop (state ${s.state}, exitCode ${s.exitCode}, crashReason ${s.crashReason}), so auto-restart never fires`); await socket.waitFor((m) => m.type === 'state' && m.serverId === id && m.state === 'running', { since, what: 'the auto-restart', timeoutMs: START_TIMEOUT }); }, { knownIssue: 'a JVM that dies from a signal exits with code null, which ServerProcess treats as a clean stop' }); - await run.step('saves settings behind a restore point while running, and restarts', async () => { - if ((await server(id)).state !== 'running') await start(id); - const since = socket.mark(); - const res = await api('POST', `/servers/${id}/edit`, { name: 'CI Lifecycle', port: 25565, memory: 1536, difficulty: 'hard', backup: true }); - assertStatus(res, 202, 'edit with backup'); - await operation(id, 'backup', since); - const saved = await operation(id, 'settings-save', since); - assert(saved.payload?.restarted === true, `payload ${JSON.stringify(saved.payload)}`); - await waitForState(api, id, ['running'], { timeoutMs: START_TIMEOUT }); - assert(/^difficulty=hard$/m.test(await readFile(id, 'server.properties')), 'difficulty not saved'); - assert((await backups(id)).some((b) => b.name === 'Pre-edit backup'), 'no Pre-edit backup'); - }); - await run.step('auto-starts across a panel restart only when asked to', async () => { + await run.step(autoStart, async () => { const restartPanel = async () => { socket.close(); docker(['restart', '-t', '60', CONTAINER]); diff --git a/test/ci/transfers.mjs b/test/ci/transfers.mjs index 8eb84db..d5f84b9 100644 --- a/test/ci/transfers.mjs +++ b/test/ci/transfers.mjs @@ -8,7 +8,7 @@ import crypto from 'node:crypto'; import { - BASE_URL, waitForPanel, bootstrapPanel, apiClient, provisionServer, waitForState, withUpstream, upstreamFetch, + BASE_URL, waitForPanel, openPanel, apiClient, provisionServer, waitForState, withUpstream, upstreamFetch, openSocket, dgupUpload, chunkHeaders, makeZip, makeModJar, makePng, pngSize, zipEntries, createRunner, assert, assertStatus } from './lib.mjs'; @@ -17,7 +17,7 @@ const PROVISION_TIMEOUT = 10 * 60_000; const run = createRunner('Transfers test'); await waitForPanel(); -const { key, session } = await bootstrapPanel(); +const { key, session } = await openPanel(); const api = apiClient(key); const socket = await openSocket({ cookie: session.cookie }); diff --git a/test/ci/websocket.mjs b/test/ci/websocket.mjs index 0a6c84c..a7da1d5 100644 --- a/test/ci/websocket.mjs +++ b/test/ci/websocket.mjs @@ -7,7 +7,7 @@ // CRAFTBOX_URL=http://localhost:6464 node test/ci/websocket.mjs import { - waitForPanel, bootstrapPanel, apiClient, provisionServer, waitForState, withUpstream, + waitForPanel, openPanel, apiClient, provisionServer, waitForState, withUpstream, openSocket, upgradeStatus, createRunner, assert, assertStatus, sleep } from './lib.mjs'; @@ -17,7 +17,7 @@ const CLIENT_ID = 'ci-websocket-test'; const run = createRunner('WebSocket test'); await waitForPanel(); -const { key, session } = await bootstrapPanel(); +const { key, session } = await openPanel(); const api = apiClient(key); const tagged = (method, path, body) => api(method, path, body, { headers: { 'x-client-id': CLIENT_ID } }); From 67946c36da79a134c26c28fe9debca7fa7fd79f6 Mon Sep 17 00:00:00 2001 From: Will Knowles <will.knowles@diamonddigital.dev> Date: Mon, 28 Sep 2026 23:21:40 +0100 Subject: [PATCH 11/13] ci: Add a weekly run of older versions, Modrinth, upgrades, arm64 and standalone The per-PR checks stay on each type's newest version inside Docker, so nothing exercised the older Java runtimes the image carries, Modrinth itself, upgrades from before the last release, the arm64 image or Craftbox run outside Docker. ci-extended.yml does, weekly, by hand, and on a PR that changes it: - server-start.mjs now takes `type@version` (and vanilla@snapshot, and custom@<version> pointed at Mojang's own jar), and with CRAFTBOX_EXPECT_JAVA checks which of the image's JREs the server runs on. The matrix covers vanilla 1.12.2 and 1.16.5 on Java 8, 1.18.2 and 1.20.4 on 17, 1.21.1 on 21, the newest snapshot, a custom jar, Paper 1.20.4, Fabric 1.16.5, Forge 1.12.2/1.16.5/1.20.1 and NeoForge 1.21.1. Its `list` check also reads 1.12's "There are 0/20 players online". - modrinth.mjs covers search and lookups, a mod installed with its required dependency and loaded on start, a plugin, and a server made from a published modpack and started. - The upgrade test runs from 1.0.0, 1.1.0 and 1.2.0. - The arm64 image is built natively on an arm64 runner and runs the API smoke test and three server starts. - Standalone jobs on Ubuntu and Windows run npm ci, the unit tests, and the API, start, WebSocket, transfers and operations tests against node src/server.js with the runner's Java. Scripts run in turn there share one sign-in through CRAFTBOX_SESSION_FILE, since the login rate limit allows five in 15 minutes, and the lifecycle section stops its server so the next one's port is free. Forge 1.12.2 can't be provisioned today: Forge's Maven publishes no .sha256 for its installers, which Craftbox requires. That is reported separately, and its entry runs as a known issue until it's fixed. --- .github/workflows/ci-extended.yml | 282 ++++++++++++++++++++++++++++++ test/ci/lib.mjs | 43 +++-- test/ci/modrinth.mjs | 107 ++++++++++++ test/ci/operations.mjs | 5 +- test/ci/server-start.mjs | 102 +++++++---- 5 files changed, 493 insertions(+), 46 deletions(-) create mode 100644 .github/workflows/ci-extended.yml create mode 100644 test/ci/modrinth.mjs diff --git a/.github/workflows/ci-extended.yml b/.github/workflows/ci-extended.yml new file mode 100644 index 0000000..a66b954 --- /dev/null +++ b/.github/workflows/ci-extended.yml @@ -0,0 +1,282 @@ +name: CI (extended) + +# The slower and more upstream-dependent checks, kept off every PR: older +# Minecraft versions and their Java runtimes, Modrinth, upgrades from older +# releases, the arm64 image, and Craftbox outside Docker on Linux and +# Windows. Runs weekly, by hand, before a release is published (called by +# docker-image.yml), and on a PR that changes this file. +on: + schedule: + - cron: '0 7 * * 1' + workflow_dispatch: + workflow_call: + pull_request: + paths: ['.github/workflows/ci-extended.yml'] + +concurrency: + group: ci-extended-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + build: + name: Build image + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build image + uses: docker/build-push-action@v6 + with: + context: . + tags: craftbox:ci + outputs: type=docker,dest=${{ runner.temp }}/craftbox-image.tar + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Upload image + uses: actions/upload-artifact@v4 + with: + name: craftbox-image-extended + path: ${{ runner.temp }}/craftbox-image.tar + retention-days: 1 + + # Each Java path the image has, through the versions that need it, and the + # loaders' older installers + older-versions: + name: Start ${{ matrix.target }} + needs: build + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - { target: vanilla@1.12.2, java: 8 } + - { target: vanilla@1.16.5, java: 8 } + - { target: vanilla@1.18.2, java: 17 } + - { target: vanilla@1.20.4, java: 17 } + - { target: vanilla@1.21.1, java: 21 } + - { target: vanilla@snapshot } + # A custom jar has no version to go by, so it gets the newest Java + - { target: custom@1.21.1, java: 25 } + - { target: paper@1.20.4, java: 17 } + - { target: fabric@1.16.5, java: 8 } + - target: forge@1.12.2 + java: 8 + known_issue: Forge's Maven publishes no .sha256 for 1.12.2 installers, and Craftbox requires one + - { target: forge@1.16.5, java: 8 } + - { target: forge@1.20.1, java: 17 } + - { target: neoforge@1.21.1, java: 21 } + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Download image + uses: actions/download-artifact@v4 + with: + name: craftbox-image-extended + path: ${{ runner.temp }} + + - name: Start Craftbox + run: | + docker load --input "$RUNNER_TEMP/craftbox-image.tar" + docker run -d --name craftbox -p 6464:6464 craftbox:ci + + - name: Start ${{ matrix.target }} + run: node test/ci/server-start.mjs ${{ matrix.target }} + env: + CRAFTBOX_URL: http://localhost:6464 + CRAFTBOX_CONTAINER: craftbox + CRAFTBOX_EXPECT_JAVA: ${{ matrix.java }} + CRAFTBOX_KNOWN_ISSUE: ${{ matrix.known_issue }} + + - name: Craftbox log + if: failure() + run: docker logs craftbox + + modrinth: + name: Modrinth test + needs: build + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Download image + uses: actions/download-artifact@v4 + with: + name: craftbox-image-extended + path: ${{ runner.temp }} + + - name: Start Craftbox + run: | + docker load --input "$RUNNER_TEMP/craftbox-image.tar" + docker run -d --name craftbox -p 6464:6464 craftbox:ci + + - name: Run Modrinth test + run: node test/ci/modrinth.mjs + env: + CRAFTBOX_URL: http://localhost:6464 + + - name: Craftbox log + if: failure() + run: docker logs craftbox + + # As in ci.yml, from each older stable release + upgrade: + name: Upgrade from ${{ matrix.from }} + needs: build + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + from: ['1.0.0', '1.1.0', '1.2.0'] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Download image + uses: actions/download-artifact@v4 + with: + name: craftbox-image-extended + path: ${{ runner.temp }} + + - name: Load image + run: docker load --input "$RUNNER_TEMP/craftbox-image.tar" + + - name: Upgrade from willtda/craftbox:${{ matrix.from }} + run: node test/ci/upgrade.mjs + env: + CRAFTBOX_URL: http://localhost:6464 + CRAFTBOX_UPGRADE_FROM: willtda/craftbox:${{ matrix.from }} + CRAFTBOX_IMAGE: craftbox:ci + + - name: Craftbox logs + if: failure() + run: | + echo "::group::Before the upgrade"; docker logs craftbox-old || true; echo "::endgroup::" + echo "::group::After the upgrade"; docker logs craftbox || true; echo "::endgroup::" + + # The image is published for arm64 too, but otherwise only ever built for + # it at release. Built and run natively here, not under emulation. + arm64: + name: arm64 image + runs-on: ubuntu-24.04-arm + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build image + uses: docker/build-push-action@v6 + with: + context: . + tags: craftbox:ci + load: true + cache-from: type=gha,scope=arm64 + cache-to: type=gha,scope=arm64,mode=max + + - name: Start Craftbox + run: docker run -d --name craftbox -p 6464:6464 craftbox:ci + + - name: Run API smoke test + run: node test/ci/api-smoke.mjs + env: + CRAFTBOX_URL: http://localhost:6464 + + - name: Start vanilla, Paper and Fabric + if: success() || failure() + run: node test/ci/server-start.mjs vanilla paper fabric + env: + CRAFTBOX_URL: http://localhost:6464 + + - name: Craftbox log + if: failure() + run: docker logs craftbox + + # Craftbox run straight from the checkout with Java from the runner, as the + # README's non-Docker install runs it (npm ci for a repeatable install, + # then what `npm start` runs) + standalone: + name: Standalone on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + defaults: + run: + shell: bash + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 25 + + - name: Install dependencies + run: npm ci + + - name: Run unit tests + run: npm test + + # The panel and the checks share one step: a background process may not + # outlive its step on Windows + - name: Run checks against Craftbox + run: | + node src/server.js > "$RUNNER_TEMP/craftbox.log" 2>&1 & + status=0 + for check in api-smoke.mjs "server-start.mjs vanilla" websocket.mjs transfers.mjs "operations.mjs lifecycle" "operations.mjs backups"; do + node test/ci/$check || status=1 + done + exit $status + env: + CRAFTBOX_URL: http://localhost:6464 + # One sign-in shared between them, clear of the login rate limit + CRAFTBOX_SESSION_FILE: ${{ runner.temp }}/craftbox-session.json + + - name: Craftbox log + if: failure() + run: cat "$RUNNER_TEMP/craftbox.log" diff --git a/test/ci/lib.mjs b/test/ci/lib.mjs index 3ddaeac..922ed4e 100644 --- a/test/ci/lib.mjs +++ b/test/ci/lib.mjs @@ -159,6 +159,12 @@ function cookieJar() { }, header() { return [...cookies].map(([k, v]) => `${k}=${v}`).join('; '); + }, + load(header) { + for (const pair of header.split(/;\s*/).filter(Boolean)) { + const eq = pair.indexOf('='); + cookies.set(pair.slice(0, eq), pair.slice(eq + 1)); + } } }; } @@ -170,11 +176,13 @@ function csrfFrom(html) { // A browser-like session: it keeps its cookies, doesn't follow redirects, // and remembers the CSRF token of the last page it rendered, which it sends -// with its next form post (`_csrf`) or API call (X-CSRF-Token) unless told -// `csrf: false`, or given a `csrf` of its own. Responses resolve to +// with its next form post (`_csrf`) or API call (X-CSRF-Token), unless given +// `csrf: null` for none or a `csrf` of its own. Responses resolve to // {status, headers, location, text, body} with `body` parsed when JSON. -export function createSession() { +// `cookie` picks up a session saved from another one. +export function createSession(cookie = '') { const jar = cookieJar(); + jar.load(cookie); let token = null; const session = { get csrf() { return token; }, @@ -211,14 +219,10 @@ export function createSession() { export const CI_USER = { username: 'ci-admin', password: 'ci-password-123' }; // Run the first-run setup wizard on a fresh instance and mint an API key. -// Returns the raw `cbx_` key. Keys can only be created from a session, so this -// goes through the same forms a browser would. -export async function bootstrapApiKey(credentials) { - return (await bootstrapPanel(credentials)).key; -} - -// As bootstrapApiKey, plus the logged-in `session` and `page(path)`, a GET on -// it resolving to {status, html}, for checking what the panel's pages render. +// Keys can only be created from a session, so this goes through the same +// forms a browser would. Resolves to the raw `cbx_` key, the logged-in +// `session`, and `page(path)`, a GET on it resolving to {status, html}, for +// checking what the panel's pages render. export async function bootstrapPanel({ username, password } = CI_USER) { const session = createSession(); const setupPage = await session.get('/setup'); @@ -244,11 +248,24 @@ export async function loginPanel({ username, password } = CI_USER) { return { key: await mintApiKey(session), session, page: (path) => pageOf(session, path) }; } -// Set up a fresh panel, or sign in to one another script already set up +// Set up a fresh panel, or sign in to one another script already set up. +// With CRAFTBOX_SESSION_FILE, scripts run one after another share a single +// signed-in session and API key through that file instead of each signing +// in, since the login rate limit allows only 5 sign-ins in 15 minutes. export async function openPanel(credentials = CI_USER) { + const file = process.env.CRAFTBOX_SESSION_FILE; + if (file && fs.existsSync(file)) { + const saved = JSON.parse(fs.readFileSync(file, 'utf8')); + const session = createSession(saved.cookie); + if ((await session.get('/account')).status === 200) { + return { key: saved.key, session, page: (path) => pageOf(session, path) }; + } + } const res = await fetch(`${BASE_URL}/setup`, { redirect: 'manual' }); await res.arrayBuffer(); - return res.status === 200 ? bootstrapPanel(credentials) : loginPanel(credentials); + const opened = res.status === 200 ? await bootstrapPanel(credentials) : await loginPanel(credentials); + if (file) fs.writeFileSync(file, JSON.stringify({ cookie: opened.session.cookie, key: opened.key })); + return opened; } async function pageOf(session, path) { diff --git a/test/ci/modrinth.mjs b/test/ci/modrinth.mjs new file mode 100644 index 0000000..24a7466 --- /dev/null +++ b/test/ci/modrinth.mjs @@ -0,0 +1,107 @@ +// Modrinth test: Craftbox's Modrinth integration against the real Modrinth, +// so it runs weekly rather than on every PR. The proxied search and +// lookups, installing a mod with its required dependency into a Fabric +// server and starting it with them loaded, a plugin into Paper, and a whole +// server created from a published modpack, then started. +// +// CRAFTBOX_URL=http://localhost:6464 node test/ci/modrinth.mjs + +import { + waitForPanel, openPanel, apiClient, provisionServer, waitForState, withUpstream, transientError, + createRunner, assert, assertStatus +} from './lib.mjs'; + +const PROVISION_TIMEOUT = 15 * 60_000; +const START_TIMEOUT = 10 * 60_000; +const FABRIC_API = 'P7dR8mSH'; +// Simply Optimized: a small (a dozen or so server-side mods) Fabric pack +// built to run on servers too +const MODPACK = 'BYfVnHa7'; + +const run = createRunner('Modrinth test'); +await waitForPanel(); +const api = apiClient((await openPanel()).key); + +// A Modrinth lookup through Craftbox, which answers 502 or 429 when +// Modrinth is down or rate limiting +async function modrinth(method, path, body) { + return withUpstream(['modrinth'], async () => { + const res = await api(method, path, body); + if (res.status === 502 || res.status === 429) throw transientError(`${path}: HTTP ${res.status} ${res.text}`); + return res; + }); +} +async function startAndStop(id, label) { + await withUpstream(['mojang'], async () => { + assertStatus(await api('POST', `/servers/${id}/start`), 200, 'start'); + await waitForState(api, id, ['running'], { timeoutMs: START_TIMEOUT, label }); + }); + assertStatus(await api('POST', `/servers/${id}/stop`), 200, 'stop'); + await waitForState(api, id, ['stopped'], { timeoutMs: 180_000, label }); +} + +console.log('Search and lookups'); +await run.step('searches modpacks, mods and plugins', async () => { + const packs = await modrinth('GET', '/modrinth/search?projectType=modpack&limit=5'); + assert(packs.status === 200 && packs.body.hits.length === 5 && packs.body.totalHits > 5, `modpacks: ${packs.status} ${packs.text.slice(0, 200)}`); + const mods = await modrinth('GET', '/modrinth/search?projectType=mod&loader=fabric&query=lithium'); + assert(mods.body.hits?.some((h) => h.slug === 'lithium'), `lithium not found: ${mods.text.slice(0, 200)}`); + const plugins = await modrinth('GET', '/modrinth/search?projectType=mod&loader=paper&query=luckperms'); + assert(plugins.body.hits?.some((h) => h.slug === 'luckperms'), `luckperms not found: ${plugins.text.slice(0, 200)}`); + assertStatus(await api('GET', '/modrinth/search?projectType=mod&query=lithium'), 400, 'a mod search without a loader'); +}); +await run.step('looks up a project and only the versions asked for', async () => { + const project = (await modrinth('GET', '/modrinth/projects/lithium')).body?.project; + assert(project?.slug === 'lithium' && project.projectType === 'mod', JSON.stringify(project)); + const mc = (await api('GET', '/versions?type=fabric')).body.latest; + const versions = (await modrinth('GET', `/modrinth/projects/lithium/versions?loader=fabric&gameVersion=${mc}`)).body.versions; + assert(versions.length > 0, `no Lithium for Fabric ${mc}`); + const off = versions.filter((v) => !v.loaders.includes('fabric') || !v.gameVersions.includes(mc)); + assert(off.length === 0, `versions outside the filter: ${off.map((v) => v.versionNumber).join(', ')}`); + assertStatus(await modrinth('GET', '/modrinth/projects/no-such-project-ci-0000'), 404, 'an unknown project'); +}); + +console.log('Installs'); +await run.step('installs a mod with its required dependency, and starts with them', async () => { + const server = await provisionServer(api, { + name: 'CI Modrinth Fabric', serverType: 'fabric', version: 'latest', port: 25565, memory: 2048, eula: true + }, { timeoutMs: PROVISION_TIMEOUT, label: 'fabric' }); + const res = await modrinth('POST', `/servers/${server.id}/modrinth-install`, { projectId: 'appleskin' }); + assert(res.status === 200, `install: ${res.status} ${res.text}`); + const projects = res.body.installed.map((i) => i.projectId); + assert(projects.length === 2 && projects.includes(FABRIC_API), `installed ${JSON.stringify(res.body.installed)}`); + const installed = (await modrinth('GET', `/servers/${server.id}/modrinth-installed`)).body.projects; + assert(Object.keys(installed).length === 2 && installed[FABRIC_API], `recognised: ${JSON.stringify(installed)}`); + assertStatus(await modrinth('POST', `/servers/${server.id}/modrinth-install`, { projectId: 'appleskin' }), 409, 'installing it again'); + await startAndStop(server.id, 'fabric with mods'); + const log = (await api('GET', `/servers/${server.id}/console?limit=400`)).body.lines.map((l) => l.line).join('\n'); + assert(/appleskin/i.test(log) && /fabric-api|fabric api/i.test(log), 'the mods weren\'t loaded'); +}); +await run.step('installs a plugin into a Paper server', async () => { + const server = await provisionServer(api, { + name: 'CI Modrinth Paper', serverType: 'paper', version: 'latest', port: 25566, memory: 2048, eula: true + }, { timeoutMs: PROVISION_TIMEOUT, label: 'paper' }); + const res = await modrinth('POST', `/servers/${server.id}/modrinth-install`, { projectId: 'luckperms' }); + assert(res.status === 200 && res.body.installed.length === 1, `install: ${res.status} ${res.text}`); + const files = (await api('GET', `/servers/${server.id}/plugins`)).body.files.map((f) => f.name); + assert(files.includes(res.body.installed[0].filename), `plugins: ${files.join(', ')}`); +}); + +console.log('Modpacks'); +await run.step('creates a server from a Modrinth modpack, and starts it', async () => { + const versions = (await modrinth('GET', `/modrinth/projects/${MODPACK}/versions?loader=fabric`)).body.versions; + const version = versions?.[0]; + assert(version, 'no Fabric version of the modpack'); + const server = await provisionServer(api, { + projectId: MODPACK, versionId: version.id, name: 'CI Modpack', port: 25567, memory: 3072, eula: true + }, { path: '/servers/from-modpack', timeoutMs: PROVISION_TIMEOUT, label: 'modpack', upstreams: ['modrinth', 'fabric', 'mojang'] }); + assert(server.modpack?.projectId === MODPACK && server.modpack.versionId === version.id && server.modpack.source === 'modrinth', + `modpack record: ${JSON.stringify(server.modpack)}`); + assert(server.serverType === 'fabric' && version.gameVersions.includes(server.version), `${server.serverType} ${server.version}`); + const mods = (await api('GET', `/servers/${server.id}/plugins`)).body.files; + assert(mods.length > 3, `only ${mods.length} mods installed`); + console.log(` ${version.name}: ${mods.length} mods, ${mods.filter((m) => m.environment === 'client').length} client-only`); + await startAndStop(server.id, 'modpack'); +}); + +run.finish(); diff --git a/test/ci/operations.mjs b/test/ci/operations.mjs index 287d451..32225d2 100644 --- a/test/ci/operations.mjs +++ b/test/ci/operations.mjs @@ -172,6 +172,8 @@ async function lifecycle() { const autoStart = 'auto-starts across a panel restart only when asked to'; if (!CONTAINER) { for (const name of [crash, autoRestart, signalled, autoStart]) run.skip(name, 'CRAFTBOX_CONTAINER not set'); + // Leave the port free for whatever uses this panel next + if ((await server(id)).state === 'running') await stop(id); return; } @@ -223,6 +225,7 @@ async function lifecycle() { const started = (await api('GET', `/servers/${id}/events?limit=5`)).body.events.find((e) => e.type === 'started'); assert(started?.initiatedBy === 'Auto Start', `started by ${started?.initiatedBy}`); }); + if ((await server(id)).state === 'running') await stop(id); } // ── Backups ── @@ -232,7 +235,7 @@ async function backupsSection() { let id = null; await run.step('creates a vanilla server', async () => { id = (await provisionServer(api, { - name: 'CI Backups', serverType: 'vanilla', version: 'latest', port: 25565, memory: 1536, eula: true + name: 'CI Backups', serverType: 'vanilla', version: 'latest', port: 25566, memory: 1536, eula: true }, { timeoutMs: PROVISION_TIMEOUT, label: 'vanilla' })).id; socket.send({ type: 'subscribe', serverId: id }); await socket.waitFor((m) => m.type === 'subscribed' && m.serverId === id, { what: 'subscribed' }); diff --git a/test/ci/server-start.mjs b/test/ci/server-start.mjs index b26edf4..772a637 100644 --- a/test/ci/server-start.mjs +++ b/test/ci/server-start.mjs @@ -1,21 +1,31 @@ -// Server start test: for each server type, create a server at the newest -// stable Minecraft version through the API, start it, wait for the "Done" -// line, round-trip a console command, stop it and delete it. +// Server start test: for each server type, create a server through the API, +// start it, wait for the "Done" line, round-trip a console command, stop it +// and delete it. // // CRAFTBOX_URL=http://localhost:6464 node test/ci/server-start.mjs paper fabric +// CRAFTBOX_URL=... node test/ci/server-start.mjs vanilla@1.12.2 forge@1.20.1 // -// With no arguments every type except `custom` is tested, one after another. -// CRAFTBOX_API_KEY reuses a key instead of running the setup wizard, so several -// invocations can share one panel. +// A bare type runs the newest stable version; `type@version` pins one, and +// vanilla@snapshot takes the newest snapshot. custom@<version> creates a +// custom server from Mojang's own jar for that version. With no arguments +// every type except `custom` is tested, one after another. +// +// It runs the setup wizard on a fresh panel and signs in to one already set +// up, so several invocations can share one panel; CRAFTBOX_API_KEY skips +// both. With CRAFTBOX_CONTAINER and CRAFTBOX_EXPECT_JAVA (a major version) +// set, it also checks which of the image's Java runtimes the server runs +// on. CRAFTBOX_KNOWN_ISSUE marks the provision step as a known issue (see +// createRunner in lib.mjs). import { - waitForPanel, bootstrapApiKey, apiClient, waitForState, provisionServer, - withUpstream, transientError, TYPE_UPSTREAMS, + CONTAINER, waitForPanel, openPanel, apiClient, waitForState, provisionServer, + withUpstream, upstreamFetch, transientError, docker, TYPE_UPSTREAMS, UPSTREAMS, createRunner, assert, assertStatus, sleep } from './lib.mjs'; const ALL_TYPES = ['vanilla', 'paper', 'purpur', 'folia', 'fabric', 'forge', 'neoforge']; -const types = process.argv.slice(2).length > 0 ? process.argv.slice(2) : ALL_TYPES; +const targets = process.argv.slice(2).length > 0 ? process.argv.slice(2) : ALL_TYPES; +const EXPECT_JAVA = process.env.CRAFTBOX_EXPECT_JAVA || null; // Forge/NeoForge run an installer while provisioning, and their first start // is the slowest of the lot; a cold runner can take a few minutes for either. @@ -37,40 +47,59 @@ async function waitForConsole(api, id, pattern, timeoutMs) { throw new Error(`console never matched ${pattern}`); } +// The version to create, as Craftbox lists it +async function resolveVersion(api, type, pinned) { + if (pinned && pinned !== 'snapshot') return pinned; + return withUpstream(TYPE_UPSTREAMS[type], async () => { + const channel = pinned === 'snapshot' ? 'all' : 'stable'; + const res = await api('GET', `/versions?type=${encodeURIComponent(type)}&channel=${channel}`); + // Craftbox answers 500 when it couldn't reach the upstream + if (res.status === 500) throw transientError(`versions: HTTP 500 ${res.text}`); + assertStatus(res, 200, 'versions'); + return pinned === 'snapshot' ? res.body.versions.find((v) => v.channel === 'snapshot')?.id : res.body.latest; + }); +} + +// Mojang's server jar for `version`, for a custom server to download +async function mojangServerJar(version) { + const manifest = await (await upstreamFetch(UPSTREAMS.mojang)).json(); + const entry = manifest.versions.find((v) => v.id === version); + assert(entry, `Mojang has no ${version}`); + return (await (await upstreamFetch(entry.url)).json()).downloads.server.url; +} + const run = createRunner('Server start test'); await waitForPanel(); -const api = apiClient(process.env.CRAFTBOX_API_KEY || await bootstrapApiKey()); +const api = apiClient(process.env.CRAFTBOX_API_KEY || (await openPanel()).key); -for (const type of types) { - console.log(`\n${type}`); +for (const target of targets) { + const [type, pinned] = target.split('@'); + console.log(`\n${target}`); let version = null; let id = null; let started = false; - await run.step(`${type}: resolve the latest stable version`, async () => { - await withUpstream(TYPE_UPSTREAMS[type], async () => { - const res = await api('GET', `/versions?type=${encodeURIComponent(type)}`); - // Craftbox answers 500 when it couldn't reach the upstream - if (res.status === 500) throw transientError(`versions: HTTP 500 ${res.text}`); - assertStatus(res, 200, 'versions'); - version = res.body.latest; - }); - assert(version, 'no stable version published'); - console.log(` latest: ${version}`); + await run.step(`${type}: resolve ${pinned ? `version ${pinned}` : 'the latest stable version'}`, async () => { + version = await resolveVersion(api, type === 'custom' ? 'vanilla' : type, pinned); + assert(version, pinned === 'snapshot' ? 'no snapshot listed' : 'no stable version published'); + console.log(` version: ${version}`); }); if (!version) continue; + const label = `${type} ${version}`; - await run.step(`${type} ${version}: provision`, async () => { - const server = await provisionServer(api, { - name: `CI ${type}`, serverType: type, version, - port: 25565, memory: 2048, eula: true - }, { timeoutMs: PROVISION_TIMEOUT, label: type }); + await run.step(`${label}: provision`, async () => { + const body = { name: `CI ${type}`, serverType: type, version, port: 25565, memory: 2048, eula: true }; + if (type === 'custom') { + body.customJarUrl = await mojangServerJar(version); + delete body.version; + } + const server = await provisionServer(api, body, { timeoutMs: PROVISION_TIMEOUT, label: type }); id = server.id; console.log(` build: ${server.build ?? 'n/a'}, Java ${server.javaMajor ?? '?'}`); - }); + }, { knownIssue: process.env.CRAFTBOX_KNOWN_ISSUE || undefined }); if (!id) continue; - await run.step(`${type} ${version}: start and reach "Done"`, async () => { + await run.step(`${label}: start and reach "Done"`, async () => { // Paper and its forks fetch Mojang's jar on their first start await withUpstream(['mojang'], async () => { assertStatus(await api('POST', `/servers/${id}/start`), 200, 'start'); @@ -84,12 +113,21 @@ for (const type of types) { }); if (started) { - await run.step(`${type} ${version}: answer a console command`, async () => { + if (EXPECT_JAVA && CONTAINER) { + await run.step(`${label}: runs on Java ${EXPECT_JAVA}`, async () => { + const jvms = docker(['exec', CONTAINER, 'ps', '-eo', 'args']).split('\n').filter((l) => /\/java\s/.test(l)); + assert(jvms.length > 0, 'no JVM running'); + assert(jvms.every((l) => l.includes(`/temurin-${EXPECT_JAVA}-`)), `JVM: ${jvms.map((l) => l.split(' ')[0]).join(', ')}`); + }); + } + + await run.step(`${label}: answer a console command`, async () => { assertStatus(await api('POST', `/servers/${id}/command`, { command: 'list' }), 200, 'command'); - await waitForConsole(api, id, /There are \d+ (of a max of \d+ )?players online/i, 30_000); + // "There are 0 of a max of 20 players online", or "0/20" before 1.13 + await waitForConsole(api, id, /There are \d+(\/\d+| of a max of \d+)? players online/i, 30_000); }); - await run.step(`${type} ${version}: stop cleanly`, async () => { + await run.step(`${label}: stop cleanly`, async () => { assertStatus(await api('POST', `/servers/${id}/stop`), 200, 'stop'); await waitForState(api, id, ['stopped'], { timeoutMs: STOP_TIMEOUT, label: type }); }); From 89213413f1e2d411b5df5643ea94a8b5f93fe885 Mon Sep 17 00:00:00 2001 From: Will Knowles <will.knowles@diamonddigital.dev> Date: Mon, 28 Sep 2026 23:21:46 +0100 Subject: [PATCH 12/13] Test a release before publishing its image Publishing a release pushed the image straight to Docker Hub, tested only by whatever CI had last run on master, and never on arm64 or with the weekly checks. The workflow now checks the release tag first, so a mislabelled release still fails in seconds, then runs both CI workflows against the release's own commit (ci.yml and ci-extended.yml, called as reusable workflows), and pushes only when every job has passed. The push builds from the cache those runs filled, amd64 from CI and arm64 from the extended run's native build, so wherever the cache holds them the layers published are the ones that were tested. A release now takes as long as the extended run before it publishes. If a check fails, an upstream outage included, re-running the failed jobs publishes once they pass. --- .github/workflows/ci.yml | 2 ++ .github/workflows/docker-image.yml | 48 +++++++++++++++++++++++++----- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b45d2bf..8d691f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,8 @@ on: # loader builds) without any commit here to trigger a run. schedule: - cron: '0 6 * * 1' + # And by docker-image.yml, before a release is published + workflow_call: concurrency: group: ci-${{ github.ref }} diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 4e8fa52..ac74ea5 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -2,20 +2,25 @@ name: Build and Publish Craftbox to Docker Hub on: release: types: [published] + +permissions: + contents: read + +env: + TAG: ${{ github.event.release.tag_name }} + PRERELEASE: ${{ github.event.release.prerelease }} + jobs: - publish_image: + # The pre-release flag decides the tags, so refuse a release whose flag + # disagrees with its version, or whose version disagrees with package.json. + # First, so a bad release fails before the long checks. + check_tag: + name: Check release tag runs-on: ubuntu-latest - permissions: - contents: read - env: - TAG: ${{ github.event.release.tag_name }} - PRERELEASE: ${{ github.event.release.prerelease }} steps: - name: Checkout uses: actions/checkout@v4 - # The pre-release flag decides the tags, so refuse a release whose flag - # disagrees with its version, or whose version disagrees with package.json. - name: Check release tag run: | if [[ "$TAG" == *-* && "$PRERELEASE" != "true" ]]; then @@ -32,6 +37,27 @@ jobs: exit 1 fi + # Every check, the weekly ones included, against the release's own commit. + # Nothing is published unless all of them pass; after a failure (an + # upstream outage included), re-run the failed jobs to publish. + ci: + name: CI + needs: check_tag + uses: ./.github/workflows/ci.yml + + ci_extended: + name: CI (extended) + needs: check_tag + uses: ./.github/workflows/ci-extended.yml + + publish_image: + name: Publish image + needs: [ci, ci_extended] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Docker metadata id: meta uses: docker/metadata-action@v5 @@ -57,6 +83,9 @@ jobs: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_TOKEN }} + # From the build cache the checks' images just filled (amd64 in CI, + # arm64 natively in CI (extended)), so wherever the cache holds them, + # the layers pushed are the ones that were tested - name: Build and push multi-arch image uses: docker/build-push-action@v6 with: @@ -64,4 +93,7 @@ jobs: platforms: linux/amd64,linux/arm64 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + cache-from: | + type=gha + type=gha,scope=arm64 push: true From 68c71248c210ef0cd1cf19234e78f244a4c48e99 Mon Sep 17 00:00:00 2001 From: Will Knowles <will.knowles@diamonddigital.dev> Date: Mon, 28 Sep 2026 23:25:44 +0100 Subject: [PATCH 13/13] ci: Never remove a container the upgrade test didn't create The upgrade test cleared the way for itself with `docker rm -f` on the names craftbox and craftbox-old. craftbox is also the container_name in docker-compose.yml, so run on a machine with the compose setup, the test would have deleted that container. It now runs its containers as craftbox-upgrade-from and craftbox-upgrade-to, labels them and its volume craftbox-ci=upgrade, removes only containers carrying that label, and stops with an explanation if the name is taken by anything else. It also stops after a failed start instead of failing every step after it. --- .github/workflows/ci-extended.yml | 4 ++-- .github/workflows/ci.yml | 4 ++-- test/ci/upgrade.mjs | 34 ++++++++++++++++++++++++------- 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci-extended.yml b/.github/workflows/ci-extended.yml index a66b954..b25c11f 100644 --- a/.github/workflows/ci-extended.yml +++ b/.github/workflows/ci-extended.yml @@ -179,8 +179,8 @@ jobs: - name: Craftbox logs if: failure() run: | - echo "::group::Before the upgrade"; docker logs craftbox-old || true; echo "::endgroup::" - echo "::group::After the upgrade"; docker logs craftbox || true; echo "::endgroup::" + echo "::group::Before the upgrade"; docker logs craftbox-upgrade-from || true; echo "::endgroup::" + echo "::group::After the upgrade"; docker logs craftbox-upgrade-to || true; echo "::endgroup::" # The image is published for arm64 too, but otherwise only ever built for # it at release. Built and run natively here, not under emulation. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d691f0..95e343f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -194,8 +194,8 @@ jobs: - name: Craftbox logs if: failure() run: | - echo "::group::Before the upgrade"; docker logs craftbox-old || true; echo "::endgroup::" - echo "::group::After the upgrade"; docker logs craftbox || true; echo "::endgroup::" + echo "::group::Before the upgrade"; docker logs craftbox-upgrade-from || true; echo "::endgroup::" + echo "::group::After the upgrade"; docker logs craftbox-upgrade-to || true; echo "::endgroup::" servers: name: Start ${{ matrix.type }} (latest) diff --git a/test/ci/upgrade.mjs b/test/ci/upgrade.mjs index 6a80dd5..42374a2 100644 --- a/test/ci/upgrade.mjs +++ b/test/ci/upgrade.mjs @@ -12,7 +12,9 @@ // CRAFTBOX_UPGRADE_FROM=willtda/craftbox:latest CRAFTBOX_IMAGE=craftbox:ci node test/ci/upgrade.mjs // // It runs the containers itself, publishing CRAFTBOX_URL's port (default -// 6464), with names craftbox-old and craftbox, and a fresh volume. +// 6464), as craftbox-upgrade-from and craftbox-upgrade-to on a fresh volume, +// all labelled craftbox-ci=upgrade. It only ever removes a container that +// carries that label, so it can't take one of yours with the same name. import crypto from 'node:crypto'; import { @@ -23,17 +25,31 @@ import { const FROM = process.env.CRAFTBOX_UPGRADE_FROM || 'willtda/craftbox:latest'; const TO = process.env.CRAFTBOX_IMAGE || 'craftbox:ci'; const PORT = new URL(BASE_URL).port || '80'; -const OLD = 'craftbox-old'; -const NEW = 'craftbox'; +const OLD = 'craftbox-upgrade-from'; +const NEW = 'craftbox-upgrade-to'; +const LABEL = 'craftbox-ci=upgrade'; const VOLUME = `craftbox-upgrade-${crypto.randomBytes(4).toString('hex')}`; // Every release so far can run it, with the Java 21 all their images carry const MC_VERSION = '1.21.1'; const run = createRunner(`Upgrade test (${FROM} → ${TO})`); +// Remove a container of ours left by an earlier run; refuse to touch anyone +// else's +function removeOurs(name) { + let label; + try { + label = docker(['inspect', '--format', '{{index .Config.Labels "craftbox-ci"}}', name]).trim(); + } catch { + return; // no such container + } + if (label !== 'upgrade') throw new Error(`A container named ${name} already exists and isn't this test's; rename or remove it first.`); + docker(['rm', '-f', name]); +} + function startContainer(name, image) { - try { docker(['rm', '-f', name]); } catch { /* not there */ } - docker(['run', '-d', '--name', name, '-p', `${PORT}:6464`, '-v', `${VOLUME}:/app/data`, image]); + removeOurs(name); + docker(['run', '-d', '--name', name, '--label', LABEL, '-p', `${PORT}:6464`, '-v', `${VOLUME}:/app/data`, image]); } // Write straight into a running panel's database, as its own user @@ -66,12 +82,16 @@ await run.step(`starts ${FROM} on a fresh volume`, async () => { } } if (lastErr) throw new UpstreamError('Docker Hub', `pulling ${FROM}: ${lastErr.stderr || lastErr.message}`); - docker(['volume', 'create', VOLUME]); + docker(['volume', 'create', '--label', LABEL, VOLUME]); startContainer(OLD, FROM); await waitForPanel(); seeded.version = (await (await fetch(`${BASE_URL}/login`)).text()).match(/v(\d+\.\d+\.\d+[^<\s"]*)/)?.[1] || 'unknown'; console.log(` running ${seeded.version}`); }); +if (run.failed) { + run.finish(); + process.exit(); +} await run.step('fills it with a server, settings, files, a backup and a template', async () => { const boot = await bootstrapPanel(); @@ -205,7 +225,7 @@ await run.step('starts the server on the new version', async () => { // Keep the containers for their logs if anything failed; CI throws them away if (run.failed === 0 && !process.env.GITHUB_ACTIONS) { - for (const name of [OLD, NEW]) docker(['rm', '-f', name]); + for (const name of [OLD, NEW]) removeOurs(name); docker(['volume', 'rm', VOLUME]); } run.finish();