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
diff --git a/.github/workflows/ci-extended.yml b/.github/workflows/ci-extended.yml
new file mode 100644
index 0000000..b25c11f
--- /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-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.
+ 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/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f181df4..95e343f 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 }}
@@ -17,6 +19,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:
@@ -80,6 +99,104 @@ 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
+ - name: WebSocket
+ script: websocket.mjs
+ # 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
+ - name: Transfers
+ script: transfers.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
+
+ # 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-upgrade-from || true; echo "::endgroup::"
+ echo "::group::After the upgrade"; docker logs craftbox-upgrade-to || true; echo "::endgroup::"
+
servers:
name: Start ${{ matrix.type }} (latest)
needs: build
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
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/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/browser.mjs b/test/ci/browser.mjs
new file mode 100644
index 0000000..09ec24b
--- /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, openPanel, 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 openPanel();
+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 efe1f9e..922ed4e 100644
--- a/test/ci/lib.mjs
+++ b/test/ci/lib.mjs
@@ -3,13 +3,134 @@
// (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`;
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).
@@ -38,84 +159,225 @@ 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));
+ }
}
};
}
+// 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;
}
-// 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 `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' } = {}) {
+// 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 given
+// `csrf: null` for none or a `csrf` of its own. Responses resolve to
+// {status, headers, location, text, body} with `body` parsed when JSON.
+// `cookie` picks up a session saved from another one.
+export function createSession(cookie = '') {
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;
+ jar.load(cookie);
+ 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' };
- const setupPage = await page('/setup');
+// Run the first-run setup wizard on a fresh instance and mint an API key.
+// 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');
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) };
+}
+
+// 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();
+ 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) {
+ 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;
+}
+
+// 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
+// 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);
+ clearTimeout(waiter.timer);
+ 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 };
+ waiter.timer = setTimeout(() => {
+ if (waiters.delete(waiter)) reject(new Error(`no ${what} within ${timeoutMs / 1000}s`));
+ }, timeoutMs);
+ waiters.add(waiter);
+ });
+ },
+ 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) {
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';
@@ -129,6 +391,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.
@@ -153,8 +442,108 @@ 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;
+ }
+ });
+}
+
+// ── 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 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) {
@@ -197,34 +586,82 @@ 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 = [];
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 });
+ } 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) {
+ 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}`);
- } 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, ' ')}`);
+ 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
+ // 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;
},
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.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) {
+ 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/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
new file mode 100644
index 0000000..32225d2
--- /dev/null
+++ b/test/ci/operations.mjs
@@ -0,0 +1,338 @@
+// 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, openPanel, 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 openPanel();
+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})`);
+ });
+
+ 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 [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;
+ }
+
+ 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 });
+ 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(autoRestart, 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(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' });
+ 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(autoStart, 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}`);
+ });
+ if ((await server(id)).state === 'running') await stop(id);
+}
+
+// ── 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: 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' });
+ });
+ 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();
diff --git a/test/ci/server-start.mjs b/test/ci/server-start.mjs
index dc13066..772a637 100644
--- a/test/ci/server-start.mjs
+++ b/test/ci/server-start.mjs
@@ -1,20 +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@ 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,
+ 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.
@@ -36,54 +47,87 @@ 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 () => {
- const res = await api('GET', `/versions?type=${encodeURIComponent(type)}`);
- 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 res = await api('POST', '/servers', {
- 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 });
+ 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 () => {
- 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)}`);
- }
+ 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');
+ 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;
});
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 });
});
diff --git a/test/ci/transfers.mjs b/test/ci/transfers.mjs
new file mode 100644
index 0000000..d5f84b9
--- /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, openPanel, 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 openPanel();
+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();
diff --git a/test/ci/upgrade.mjs b/test/ci/upgrade.mjs
new file mode 100644
index 0000000..42374a2
--- /dev/null
+++ b/test/ci/upgrade.mjs
@@ -0,0 +1,231 @@
+// 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), 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 {
+ 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-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) {
+ 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
+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', '--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();
+ 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]) removeOurs(name);
+ docker(['volume', 'rm', VOLUME]);
+}
+run.finish();
diff --git a/test/ci/web.mjs b/test/ci/web.mjs
new file mode 100644
index 0000000..d0736fc
--- /dev/null
+++ b/test/ci/web.mjs
@@ -0,0 +1,327 @@
+// 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, panelPages,
+ 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 ? panelPages(vanilla, fabric) : [];
+
+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(/]+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(/