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(/<link[^>]+href="(\/[^"/][^"]*)"/g)) assets.add(url); + } + assert(assets.size > 10, `only found ${assets.size} assets`); + const types = { js: /javascript/, css: /text\/css/, png: /image\/png/, svg: /image\/svg/, ico: /image\//, json: /json/ }; + const broken = []; + for (const url of assets) { + const res = await fetch(BASE_URL + url, { headers: { cookie: session.cookie } }); + const ext = url.split('?')[0].split('.').pop(); + await res.arrayBuffer(); + if (res.status !== 200) broken.push(`${url}: HTTP ${res.status}`); + else if (types[ext] && !types[ext].test(res.headers.get('content-type') || '')) broken.push(`${url}: ${res.headers.get('content-type')}`); + } + assert(broken.length === 0, broken.join('\n')); + console.log(` ${assets.size} assets`); +}); +await run.step('sends the security headers, with a nonce the inline script carries', async () => { + const res = rendered.get('/dashboard'); + assert(res, 'dashboard not rendered'); + const csp = res.headers.get('content-security-policy') || ''; + const nonce = /'nonce-([^']+)'/.exec(csp)?.[1]; + assert(nonce && /default-src 'self'/.test(csp) && /frame-ancestors 'none'/.test(csp), `CSP: ${csp}`); + const inline = [...res.text.matchAll(/<script(?![^>]*\bsrc=)([^>]*)>/g)].map((m) => m[1]); + assert(inline.length > 0 && inline.every((attrs) => attrs.includes(`nonce="${nonce}"`)), `inline scripts: ${JSON.stringify(inline)}`); + for (const [header, want] of [['x-frame-options', 'DENY'], ['x-content-type-options', 'nosniff']]) { + assert(res.headers.get(header) === want, `${header}: ${res.headers.get(header)}`); + } +}); +await run.step('answers bad addresses with the error pages', async () => { + const cases = vanilla ? [ + ['/no-such-page', 404, 'Not Found'], + [`/servers/${UNKNOWN_ID}`, 404, 'Not Found'], + ['/servers/not-a-uuid', 404, 'Not Found'], + [`/servers/${vanilla.id}/plugins`, 404, 'Not Found'], + [`/servers/${vanilla.id}/edit-file?path=../../craftbox.sqlite`, 403, 'Forbidden'], + [`/servers/${vanilla.id}/files/no-such-dir`, 404, 'Not Found'] + ] : []; + const wrong = []; + for (const [path, status, want] of cases) { + const res = await session.get(path); + if (res.status !== status || title(res.text) !== `${want} | Craftbox`) wrong.push(`${path}: ${res.status} "${title(res.text)}"`); + } + assert(wrong.length === 0, wrong.join('\n')); +}); +await run.step('sends signed-out visitors to sign in', async () => { + for (const [path] of pages().filter(([p]) => !p.startsWith('/status'))) { + const res = await anon.get(path); + assert(res.status === 302 && res.location === '/login', `${path}: ${res.status} → ${res.location}`); + } + const res = await anon.get('/api/v1/servers'); + assert(res.status === 401 && res.body?.error === 'unauthorized', `API: ${res.status} ${res.text}`); +}); + +console.log('Signing in'); +await run.step('turns away a wrong password', async () => { + const visitor = createSession(); + await visitor.get('/login'); + const res = await visitor.post('/login', { username: CI_USER.username, password: 'not-the-password' }); + assert(res.status === 302 && res.location === '/login', `${res.status} → ${res.location}`); + const shown = flash((await visitor.get('/login')).text); + assert(shown === 'Invalid username or password.', `showed ${JSON.stringify(shown)}`); + assert((await visitor.get('/dashboard')).location === '/login', 'signed in anyway'); +}); +let second = null; +await run.step('signs in and returns to the page that asked', async () => { + second = createSession(); + const asked = await second.get('/servers/create'); + assert(asked.location === '/login', `${asked.status} → ${asked.location}`); + await second.get('/login'); + const before = sidOf(second.cookie); + const res = await second.post('/login', CI_USER); + assert(res.status === 302 && res.location === '/servers/create', `${res.status} → ${res.location}`); + assert(sidOf(second.cookie) !== before, 'signing in kept the pre-login session id'); + assertStatus(await second.get('/servers/create'), 200, 'create page'); + assert((await second.get('/login')).location === '/dashboard', 'login page shown while signed in'); +}); +await run.step('keeps the session cookie HttpOnly, SameSite=Strict and an hour from last use', async () => { + const res = await fetch(`${BASE_URL}/dashboard`, { headers: { cookie: session.cookie }, redirect: 'manual' }); + const cookie = res.headers.getSetCookie().find((c) => c.startsWith('connect.sid=')); + assert(cookie, 'no cookie refresh on a page load'); + assert(/;\s*HttpOnly/i.test(cookie) && /;\s*SameSite=Strict/i.test(cookie), cookie); + const expires = Date.parse(/Expires=([^;]+)/i.exec(cookie)?.[1]); + const lifetime = (expires - Date.parse(res.headers.get('date'))) / 1000; + assert(lifetime > 3590 && lifetime <= 3600, `cookie expires in ${lifetime}s`); +}); + +console.log('CSRF'); +await run.step('the API takes a session, but its changes need the page\'s token', async () => { + assertStatus(await session.api('GET', '/servers'), 200, 'GET with a session'); + const path = `/servers/${vanilla?.id}/motd`; + const missing = await session.api('POST', path, { motd: 'no token' }, { csrf: null }); + assert(missing.status === 403 && missing.body?.error === 'forbidden', `no token: ${missing.status} ${missing.text}`); + const wrong = await session.api('POST', path, { motd: 'wrong token' }, { csrf: 'f'.repeat(64) }); + assert(wrong.status === 403, `wrong token: ${wrong.status}`); + const other = await session.api('POST', path, { motd: 'another session\'s token' }, { csrf: second?.csrf || 'e'.repeat(64) }); + assert(other.status === 403, `another session's token: ${other.status}`); + await session.get('/dashboard'); + assertStatus(await session.api('POST', path, { motd: 'CI web' }), 200, 'with the token'); +}); +await run.step('a signed-in form posted without the token is a 403 page', async () => { + const res = await session.post('/account', { currentPassword: CI_USER.password, newUsername: 'renamed' }, { csrf: null }); + assert(res.status === 403 && title(res.text) === 'Forbidden | Craftbox', `${res.status} "${title(res.text)}"`); + assert(res.text.includes('Invalid or missing CSRF token'), 'no explanation on the page'); +}); + +console.log('Signing out and expiry'); +await run.step('signing out needs the token, then ends the session', async () => { + const refused = await second.post('/logout', {}, { csrf: null }); + assertStatus(refused, 403, 'logout without a token'); + await second.get('/dashboard'); + const res = await second.post('/logout', {}); + assert(res.status === 302 && res.location === '/login', `${res.status} → ${res.location}`); + assert((await second.get('/dashboard')).location === '/login', 'still signed in after logout'); + assertStatus(await second.api('GET', '/servers'), 401, 'API after logout'); +}); +if (CONTAINER) { + await run.step('an expired session is signed out, and its forms go to sign in', async () => { + const stale = createSession(); + await stale.get('/login'); + const login = await stale.post('/login', CI_USER); + assert(login.status === 302 && login.location === '/dashboard', `login: ${login.status} → ${login.location}`); + await stale.get('/account'); + // Push the stored session past its expiry, as an hour's idle would + const sid = decodeURIComponent(sidOf(stale.cookie)).slice(2).split('.')[0]; + const out = containerExec(['node', '-e', ` + const db = new (require('better-sqlite3'))('data/craftbox.sqlite'); + const row = db.prepare('SELECT json FROM sessions WHERE ID = ?').get('sess_' + process.argv[1]); + if (!row) { console.log('missing'); process.exit(); } + const value = JSON.parse(row.json); + value.expires = Date.now() - 1000; + db.prepare('UPDATE sessions SET json = ? WHERE ID = ?').run(JSON.stringify(value), 'sess_' + process.argv[1]); + console.log('aged');`, sid]).trim(); + assert(out === 'aged', `could not age the session: ${out}`); + assert((await stale.get('/dashboard')).location === '/login', 'expired session still signed in'); + const form = await stale.post('/account', { currentPassword: CI_USER.password, newUsername: 'renamed' }); + assert(form.status === 302 && form.location === '/login', `form from an expired session: ${form.status} → ${form.location}`); + }); +} else { + run.skip('an expired session is signed out, and its forms go to sign in', 'CRAFTBOX_CONTAINER not set'); +} + +console.log('Status pages'); +await run.step('lists only servers whose status page is public', async () => { + assertStatus(await api('POST', `/servers/${vanilla.id}/statuspublic`, { enabled: true }), 200, 'statuspublic on'); + assertStatus(await api('POST', `/servers/${fabric.id}/statuspublic`, { enabled: false }), 200, 'statuspublic off'); + const html = (await anon.get('/status')).text; + assert(html.includes(`href="/status/${vanilla.id}"`), 'public server not listed'); + assert(!html.includes(fabric.id), 'non-public server listed'); +}); +await run.step('shows any server by its link, with nothing internal in it', async () => { + // Listing is all the flag controls; the UUID is the capability (docs/API.md) + const page = await anon.get(`/status/${fabric.id}`); + assert(page.status === 200 && title(page.text) === 'CI Web Fabric Status | Craftbox', `${page.status} "${title(page.text)}"`); + const res = await anon.get(`/status/${vanilla.id}/api`); + assertStatus(res, 200, 'status JSON'); + const keys = Object.keys(res.body.server).sort().join(); + const documented = ['advertisedIp', 'id', 'name', 'playerCount', 'players', 'port', 'serverType', 'state', 'statusPagePublic', + 'uptime', 'uptimeFormatted', 'version'].sort().join(); + assert(keys === documented, `keys: ${keys}`); + for (const path of [`/status/${UNKNOWN_ID}`, '/status/not-a-uuid', `/status/${UNKNOWN_ID}/api`, `/status/${UNKNOWN_ID}/mods`]) { + assertStatus(await anon.get(path), 404, path); + } +}); +await run.step('offers players the client-side mods as a zip', async () => { + assertStatus(await anon.get(`/status/${fabric.id}/mods`), 404, 'no mods yet'); + const form = new FormData(); + form.append('file', new Blob([makeModJar('fabric', 'ci_client')]), 'ci-client.jar'); + form.append('file', new Blob([makeModJar('fabric', 'ci_server')]), 'ci-server.jar'); + const upload = await api('POST', `/servers/${fabric.id}/plugins/upload`, form); + assert(upload.status === 200 && upload.body.count === 2, `upload: ${upload.status} ${upload.text}`); + assertStatus(await api('POST', `/servers/${fabric.id}/plugins/environment`, { filename: 'ci-server.jar', environment: 'server' }), 200, 'server-only'); + + const res = await fetch(`${BASE_URL}/status/${fabric.id}/mods`); + const zip = Buffer.from(await res.arrayBuffer()); + assert(res.status === 200, `mods zip: HTTP ${res.status}`); + assert(Number(res.headers.get('content-length')) === zip.length, `Content-Length ${res.headers.get('content-length')}, body ${zip.length}`); + assert(zipEntries(zip).join() === 'mods/ci-client.jar', `zip holds ${zipEntries(zip).join(', ')}`); + const page = (await anon.get(`/status/${fabric.id}`)).text; + assert(page.includes(`/status/${fabric.id}/mods`), 'status page has no mods download'); +}); + +// Last: from here on this address can't sign in for 15 minutes +console.log('Rate limiting'); +await run.step('refuses a sixth sign-in inside 15 minutes, even with the right password', async () => { + const visitor = createSession(); + await visitor.get('/login'); + let remaining = null; + for (let attempt = 0; attempt < 6; attempt++) { + const res = await visitor.post('/login', { username: CI_USER.username, password: 'not-the-password' }); + if (res.status === 429) break; + assert(res.headers.get('ratelimit-limit') === '5', `RateLimit-Limit ${res.headers.get('ratelimit-limit')}`); + remaining = Number(res.headers.get('ratelimit-remaining')); + } + assert(remaining === 0, `the limit ran out with ${remaining} attempts left`); + const res = await visitor.post('/login', CI_USER); + assert(res.status === 429 && title(res.text) === 'Too Many Requests | Craftbox', `right password: ${res.status} "${title(res.text)}"`); + assert(res.text.includes('Too many login attempts'), 'no explanation on the page'); + assertStatus(await session.get('/dashboard'), 200, 'a session already signed in is unaffected'); +}); + +run.finish(); diff --git a/test/ci/websocket.mjs b/test/ci/websocket.mjs new file mode 100644 index 0000000..a7da1d5 --- /dev/null +++ b/test/ci/websocket.mjs @@ -0,0 +1,178 @@ +// WebSocket test: the live protocol the panel's pages run on (docs/API.md, +// "WebSocket protocol"), against a vanilla server started for real. Session +// auth on the upgrade, subscribing, the console stream, `command` (whose +// field is `line`), state, event, operation and change broadcasts, and the +// public /ws/status socket's reduced view. +// +// CRAFTBOX_URL=http://localhost:6464 node test/ci/websocket.mjs + +import { + waitForPanel, openPanel, apiClient, provisionServer, waitForState, withUpstream, + openSocket, upgradeStatus, createRunner, assert, assertStatus, sleep +} from './lib.mjs'; + +const PROVISION_TIMEOUT = 10 * 60_000; +const START_TIMEOUT = 5 * 60_000; +const CLIENT_ID = 'ci-websocket-test'; + +const run = createRunner('WebSocket test'); +await waitForPanel(); +const { key, session } = await openPanel(); +const api = apiClient(key); +const tagged = (method, path, body) => api(method, path, body, { headers: { 'x-client-id': CLIENT_ID } }); + +let socket = null; +let pub = null; +let id = null; + +console.log('Connecting'); +await run.step('refuses an upgrade without a signed-in session', async () => { + assert(await upgradeStatus('/') === 401, 'no cookie'); + assert(await upgradeStatus('/', { cookie: 'connect.sid=s%3Anot-a-session.sig' }) === 401, 'forged cookie'); + assert(await upgradeStatus('/', { authorization: `Bearer ${key}` }) === 401, 'an API key is not a session'); +}); +await run.step('opens for a session, and publicly on /ws/status', async () => { + socket = await openSocket({ cookie: session.cookie }); + pub = await openSocket({ path: '/ws/status' }); +}); +if (!socket || !pub) { + run.finish(); + process.exit(); +} +await run.step('answers ping and reports protocol errors', async () => { + const reply = async (msg, what) => { + const since = socket.mark(); + socket.send(msg); + return socket.waitFor((m) => m.type === 'pong' || m.type === 'error', { since, what, timeoutMs: 5000 }); + }; + assert((await reply({ type: 'ping' }, 'pong')).type === 'pong', 'no pong'); + for (const [msg, error] of [ + ['not json', 'Invalid JSON.'], + [{ type: 'bogus' }, 'Unknown message type: bogus'], + [{ type: 'subscribe' }, 'Missing serverId.'], + [{ type: 'command', line: 'list' }, 'Missing serverId or line.'] + ]) { + const got = await reply(msg, `error for ${JSON.stringify(msg)}`); + assert(got.type === 'error' && got.message === error, `${JSON.stringify(msg)}: ${JSON.stringify(got)}`); + } +}); + +console.log('Server'); +await run.step('tells every signed-in socket, and no public one, when servers change', async () => { + const since = socket.mark(); + const pubSince = pub.mark(); + const server = await provisionServer(api, { + name: 'CI WebSocket', serverType: 'vanilla', version: 'latest', port: 25565, memory: 1536, eula: true + }, { timeoutMs: PROVISION_TIMEOUT, label: 'vanilla' }); + id = server.id; + await socket.waitFor((m) => m.type === 'dashboard-changed', { since, what: 'dashboard-changed on create' }); + const regroup = socket.mark(); + assertStatus(await tagged('POST', `/servers/${id}/group`, { group: 'CI' }), 200, 'group'); + const msg = await socket.waitFor((m) => m.type === 'dashboard-changed', { since: regroup, what: 'dashboard-changed on regroup' }); + assert(msg.origin === CLIENT_ID, `origin ${JSON.stringify(msg.origin)}, expected the request's X-Client-Id`); + assert(!pub.messages.slice(pubSince).some((m) => m.type === 'dashboard-changed'), 'the public socket was told'); +}); +if (!id) { + run.finish(); + process.exit(); +} +await run.step('sends a snapshot on subscribe, fuller for a session than in public', async () => { + socket.send({ type: 'subscribe', serverId: id }); + pub.send({ type: 'subscribe', serverId: id }); + const full = await socket.waitFor((m) => m.type === 'subscribed' && m.serverId === id, { what: 'subscribed' }); + const reduced = await pub.waitFor((m) => m.type === 'subscribed' && m.serverId === id, { what: 'public subscribed' }); + assert(full.state === 'stopped' && Array.isArray(full.history) && 'crashReason' in full, `session: ${JSON.stringify(full)}`); + assert(reduced.state === 'stopped', `public state ${reduced.state}`); + const leaked = ['history', 'crashReason', 'exitCode'].filter((k) => k in reduced); + assert(leaked.length === 0, `public snapshot has ${leaked.join(', ')}`); +}); +await run.step('refuses a command while the server is stopped', async () => { + const since = socket.mark(); + socket.send({ type: 'command', serverId: id, line: 'list' }); + const got = await socket.waitFor((m) => m.type === 'error', { since, what: 'error', timeoutMs: 5000 }); + assert(got.message === 'Server is not running.', got.message); +}); +await run.step('tells subscribers a listing changed, naming the directory', async () => { + const since = socket.mark(); + assertStatus(await tagged('POST', `/servers/${id}/files/mkdir`, { name: 'ci-ws' }), [200, 201], 'mkdir'); + const msg = await socket.waitFor((m) => m.type === 'content-changed' && m.serverId === id, { since, what: 'content-changed' }); + assert(msg.scope === 'files' && msg.path === '' && msg.origin === CLIENT_ID, JSON.stringify(msg)); +}); + +console.log('Running'); +let running = false; +await run.step('streams state changes and console output from a start', async () => { + const since = socket.mark(); + const pubSince = pub.mark(); + await withUpstream(['mojang'], async () => { + assertStatus(await api('POST', `/servers/${id}/start`), 200, 'start'); + await waitForState(api, id, ['running'], { timeoutMs: START_TIMEOUT, label: 'vanilla' }); + }); + running = true; + const states = () => socket.messages.slice(since).filter((m) => m.type === 'state' && m.serverId === id).map((m) => m.state); + await socket.waitFor((m) => m.type === 'state' && m.state === 'running', { since, what: 'state running' }); + assert(states().join() === 'starting,running', `states: ${states().join()}`); + const done = await socket.waitFor((m) => m.type === 'console' && /Done \(/.test(m.line), { since, what: 'the "Done" line' }); + assert(done.serverId === id && done.timestamp, JSON.stringify(done)); + const started = await socket.waitFor((m) => m.type === 'event' && m.eventType === 'started', { since, what: 'started event' }); + assert(started.serverId === id && started.createdAt, JSON.stringify(started)); + + await pub.waitFor((m) => m.type === 'state' && m.state === 'running', { since: pubSince, what: 'public state running' }); + await pub.waitFor((m) => m.type === 'event' && m.eventType === 'started', { since: pubSince, what: 'public started event' }); + assert(!pub.messages.slice(pubSince).some((m) => m.type === 'console'), 'console output reached the public socket'); +}); +if (running) { + await run.step('runs a `command` message and streams the reply', async () => { + const since = socket.mark(); + socket.send({ type: 'command', serverId: id, line: 'list' }); + await socket.waitFor((m) => m.type === 'console' && /There are \d+ (of a max of \d+ )?players online/i.test(m.line), + { since, what: 'the reply to "list"' }); + socket.send({ type: 'command', serverId: id, line: 'say hello from the CI WebSocket test' }); + await socket.waitFor((m) => m.type === 'console' && /hello from the CI WebSocket test/.test(m.line), { since, what: 'the "say" echo' }); + }); + await run.step('refuses commands on the public socket', async () => { + const since = pub.mark(); + pub.send({ type: 'command', serverId: id, line: 'op someone' }); + const got = await pub.waitFor((m) => m.type === 'error', { since, what: 'error', timeoutMs: 5000 }); + assert(got.message === 'Commands not available on public connections.', got.message); + await sleep(1000); + assert(!socket.messages.some((m) => m.type === 'console' && /someone/.test(m.line)), 'the command ran'); + }); + await run.step('streams the stop', async () => { + const since = socket.mark(); + assertStatus(await api('POST', `/servers/${id}/stop`), 200, 'stop'); + await socket.waitFor((m) => m.type === 'state' && m.state === 'stopped', { since, what: 'state stopped', timeoutMs: 120_000 }); + const states = socket.messages.slice(since).filter((m) => m.type === 'state').map((m) => m.state); + assert(states.join() === 'stopping,stopped', `states: ${states.join()}`); + await socket.waitFor((m) => m.type === 'event' && m.eventType === 'stopped', { since, what: 'stopped event' }); + }); +} + +console.log('Operations'); +await run.step('reports a backup as an `operation`', async () => { + const since = socket.mark(); + assertStatus(await api('POST', `/servers/${id}/backups`, { name: 'CI WebSocket' }), 202, 'backup'); + const msg = await socket.waitFor((m) => m.type === 'operation' && m.operation === 'backup' && m.status !== 'progress', + { since, what: 'backup operation', timeoutMs: 120_000 }); + assert(msg.status === 'complete' && msg.serverId === id, JSON.stringify(msg)); + const states = socket.messages.slice(since).filter((m) => m.type === 'state').map((m) => m.state); + assert(states.join() === 'backing_up,stopped', `states: ${states.join()}`); +}); +await run.step('tells subscribers when the event log is cleared', async () => { + const since = socket.mark(); + assertStatus(await api('POST', `/servers/${id}/events/clear`), 200, 'clear'); + await socket.waitFor((m) => m.type === 'events_cleared' && m.serverId === id, { since, what: 'events_cleared' }); +}); +await run.step('stops sending after unsubscribe', async () => { + socket.send({ type: 'unsubscribe', serverId: id }); + await sleep(500); + const since = socket.mark(); + assertStatus(await api('POST', `/servers/${id}/files/mkdir`, { name: 'ci-ws-2' }), [200, 201], 'mkdir'); + await sleep(2000); + const got = socket.messages.slice(since).filter((m) => m.serverId === id); + assert(got.length === 0, `still received ${got.map((m) => m.type).join(', ')}`); +}); + +socket.close(); +pub.close(); +run.finish(); diff --git a/test/unit/fileSafety.test.js b/test/unit/fileSafety.test.js new file mode 100644 index 0000000..818aa8f --- /dev/null +++ b/test/unit/fileSafety.test.js @@ -0,0 +1,61 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { isPathInside } = require('../../src/utils/pathSafety'); +const { isZipFile } = require('../../src/utils/uploadSafety'); + +function tempDir(t) { + const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'craftbox-safety-'))); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + return dir; +} + +test('isPathInside allows the directory and anything beneath it', (t) => { + const base = tempDir(t); + fs.mkdirSync(path.join(base, 'world')); + assert.equal(isPathInside(base, base), true); + assert.equal(isPathInside(base, path.join(base, 'world')), true); + assert.equal(isPathInside(base, path.join(base, 'world', 'not-yet', 'level.dat')), true, 'a file about to be written'); +}); + +test('isPathInside refuses traversal and look-alike siblings', (t) => { + const base = tempDir(t); + const server = path.join(base, 'server'); + fs.mkdirSync(server); + fs.mkdirSync(path.join(base, 'server-evil')); + assert.equal(isPathInside(server, path.join(server, '..')), false); + assert.equal(isPathInside(server, path.join(server, '..', 'craftbox.sqlite')), false); + assert.equal(isPathInside(server, path.join(base, 'server-evil', 'x')), false, 'prefix match is not containment'); + assert.equal(isPathInside(path.join(base, 'missing'), path.join(base, 'missing', 'x')), false, 'no base directory'); +}); + +test('isPathInside follows symlinks out of the directory', (t) => { + const base = tempDir(t); + const server = path.join(base, 'server'); + const outside = path.join(base, 'outside'); + fs.mkdirSync(server); + fs.mkdirSync(outside); + try { + fs.symlinkSync(outside, path.join(server, 'link'), 'junction'); + } catch (err) { + return t.skip(`cannot create a link here: ${err.code}`); + } + assert.equal(isPathInside(server, path.join(server, 'link', 'secret.txt')), false); +}); + +test('isZipFile checks the magic number, not the name', (t) => { + const dir = tempDir(t); + const file = (name, bytes) => { + const p = path.join(dir, name); + fs.writeFileSync(p, Buffer.from(bytes)); + return p; + }; + assert.equal(isZipFile(file('a.jar', [0x50, 0x4b, 0x03, 0x04, 0, 0])), true, 'local file header'); + assert.equal(isZipFile(file('empty.zip', [0x50, 0x4b, 0x05, 0x06, 0, 0])), true, 'empty archive'); + assert.equal(isZipFile(file('spanned.zip', [0x50, 0x4b, 0x07, 0x08])), true, 'spanned archive'); + assert.equal(isZipFile(file('fake.jar', [0x7f, 0x45, 0x4c, 0x46])), false, 'an ELF binary named .jar'); + assert.equal(isZipFile(file('short.jar', [0x50, 0x4b])), false, 'too short'); + assert.equal(isZipFile(path.join(dir, 'missing.jar')), false); +}); diff --git a/test/unit/javaVersion.test.js b/test/unit/javaVersion.test.js new file mode 100644 index 0000000..5095c4d --- /dev/null +++ b/test/unit/javaVersion.test.js @@ -0,0 +1,51 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const { getRequiredJavaVersion, getJavaForMajor, TEMURIN_PATHS } = require('../../src/utils/javaVersion'); + +test('releases map to the Java they need', () => { + const cases = { + '1.7.10': 8, '1.12.2': 8, '1.16.5': 8, + '1.17': 17, '1.17.1': 17, '1.18.2': 17, '1.20.4': 17, + '1.20.5': 21, '1.20.6': 21, '1.21': 21, '1.21.1': 21, '1.21.11': 21, + '26.1': 25, '26.1.2': 25, '27.1': 25 + }; + for (const [version, java] of Object.entries(cases)) { + assert.equal(getRequiredJavaVersion(version), java, version); + } +}); + +test('pre-releases and release candidates need their release\'s Java', () => { + assert.equal(getRequiredJavaVersion('1.20.5-pre1'), 21); + assert.equal(getRequiredJavaVersion('1.20.5-rc1'), 21); + assert.equal(getRequiredJavaVersion('1.17-pre1'), 17); + assert.equal(getRequiredJavaVersion('1.14 Pre-Release 5'), 8); +}); + +test('snapshots map by year', () => { + assert.equal(getRequiredJavaVersion('20w45a'), 8); + assert.equal(getRequiredJavaVersion('21w37a'), 17); + assert.equal(getRequiredJavaVersion('23w51b'), 17); + assert.equal(getRequiredJavaVersion('24w14a'), 21); + assert.equal(getRequiredJavaVersion('25w03a'), 21); + assert.equal(getRequiredJavaVersion('26w14a'), 25); +}); + +test('NeoForge\'s pseudo 1.26+ ids are the year-based era', () => { + assert.equal(getRequiredJavaVersion('1.26.1'), 25); +}); + +test('no version (a custom jar) gets the newest Java', () => { + for (const v of [undefined, null, '', 26]) assert.equal(getRequiredJavaVersion(v), 25, String(v)); +}); + +test('the image\'s JREs are all known', () => { + assert.deepEqual(Object.keys(TEMURIN_PATHS).map(Number), [8, 17, 21, 25]); + for (const p of Object.values(TEMURIN_PATHS)) assert.match(p, /^\/usr\/lib\/jvm\/temurin-\d+-jre-[a-z0-9]+\/bin\/java$/); +}); + +const hasTemurin = process.platform !== 'win32' && Object.values(TEMURIN_PATHS).some((p) => fs.existsSync(p)); + +test('outside the Docker image, Java comes from PATH', { skip: hasTemurin && 'running where the Temurin JREs are installed' }, () => { + for (const major of [8, 17, 21, 25]) assert.equal(getJavaForMajor(major), 'java'); +}); diff --git a/test/unit/neoforge.test.js b/test/unit/neoforge.test.js new file mode 100644 index 0000000..a593756 --- /dev/null +++ b/test/unit/neoforge.test.js @@ -0,0 +1,76 @@ +// NeoForge names its builds after the Minecraft version they target, and +// that naming changed with Minecraft's year-based versions. The mapping is +// private to the module, so these drive it through listVersions/getBuilds +// with Maven's version list stubbed. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const neoforge = require('../../src/mc/serverTypes/neoforge'); +const { relabelLegacyVersion } = neoforge; + +const MAVEN_VERSIONS = [ + '20.2.3-beta', + '20.4.237', + '20.6.119', + '21.0.167', + '21.1.1', '21.1.95', '21.1.100', + '21.9.16-beta', '21.9.16', + '0.25w14craftmine.3-beta', + '26.1.0.7', + '26.1.1.3', + '26.1.2.112', + '26.2.0.88-beta' +]; + +test.beforeEach((t) => { + t.mock.method(globalThis, 'fetch', async (url) => { + assert.match(String(url), /maven\.neoforged\.net\/api\/maven\/versions\/releases\/net\/neoforged\/neoforge$/); + return { ok: true, status: 200, json: async () => ({ versions: MAVEN_VERSIONS }) }; + }); +}); + +test('builds are listed under the Minecraft version they target', async () => { + const { versions, latest } = await neoforge.listVersions(); + assert.deepEqual(versions.map((v) => v.id), ['26.1.2', '26.1.1', '26.1', '1.21.9', '1.21.1', '1.21', '1.20.6', '1.20.4']); + assert.ok(versions.every((v) => v.channel === 'stable')); + assert.equal(latest, '26.1.2'); +}); + +test('versions with only beta builds appear on the "all" channel', async () => { + const { versions, latest } = await neoforge.listVersions({ channel: 'all' }); + const channel = Object.fromEntries(versions.map((v) => [v.id, v.channel])); + assert.equal(channel['26.2'], 'beta'); + assert.equal(channel['1.20.2'], 'beta'); + assert.equal(channel['1.21.9'], 'stable', 'a version with a stable build is stable'); + assert.equal(versions[0].id, '26.2'); + assert.equal(latest, '26.1.2', 'latest is never a beta'); + assert.ok(!versions.some((v) => /craftmine|^0\./.test(v.id)), 'April Fools builds excluded'); +}); + +test('getBuilds lists one version\'s builds, newest first', async () => { + assert.deepEqual((await neoforge.getBuilds('1.21.1')).map((b) => b.build), ['21.1.100', '21.1.95', '21.1.1']); + assert.deepEqual(await neoforge.getBuilds('1.21.9'), [ + { build: '21.9.16', channel: 'release' }, + { build: '21.9.16-beta', channel: 'beta' } + ]); + assert.deepEqual((await neoforge.getBuilds('26.1')).map((b) => b.build), ['26.1.0.7']); + assert.deepEqual((await neoforge.getBuilds('26.1.2')).map((b) => b.build), ['26.1.2.112']); + assert.deepEqual((await neoforge.getBuilds('1.21')).map((b) => b.build), ['21.0.167']); +}); + +test('getBuilds accepts a pre-1.2.1 "1.26.x" label', async () => { + assert.deepEqual((await neoforge.getBuilds('1.26.1')).map((b) => b.build), ['26.1.0.7']); +}); + +test('relabelLegacyVersion reads the real version off the build', () => { + assert.equal(relabelLegacyVersion('1.26.1', '26.1.2.112'), '26.1.2'); + assert.equal(relabelLegacyVersion('1.26.1', '26.1.0.7'), '26.1'); + assert.equal(relabelLegacyVersion('1.26.1'), '26.1'); + assert.equal(relabelLegacyVersion('1.26.2', '21.1.5'), '26.2', 'a build in another form is ignored'); +}); + +test('relabelLegacyVersion leaves real versions alone', () => { + assert.equal(relabelLegacyVersion('1.21.1', '21.1.95'), '1.21.1'); + assert.equal(relabelLegacyVersion('26.1', '26.1.0.7'), '26.1'); + assert.equal(relabelLegacyVersion('26.1.2', '26.1.2.112'), '26.1.2'); + assert.equal(relabelLegacyVersion(null), null); +}); diff --git a/test/unit/serverProperties.test.js b/test/unit/serverProperties.test.js new file mode 100644 index 0000000..aab08d8 --- /dev/null +++ b/test/unit/serverProperties.test.js @@ -0,0 +1,199 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { + writeServerProperties, writeEula, parseServerProperties, updateServerProperties, DEFAULT_PROPERTIES +} = require('../../src/mc/serverProperties'); +const { PROPERTY_META } = require('../../src/mc/propertyMeta'); + +function tempDir(t) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'craftbox-props-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + return dir; +} + +const read = (dir) => fs.readFileSync(path.join(dir, 'server.properties'), 'utf8'); +const write = (dir, content) => fs.writeFileSync(path.join(dir, 'server.properties'), content); + +// The keys of a written file, in order, ignoring comments +const keysOf = (content) => content.split('\n') + .filter((l) => l && !l.startsWith('#')) + .map((l) => l.slice(0, l.indexOf('='))); + +test('parse undoes the escapes Minecraft writes and tolerates spacing', (t) => { + const dir = tempDir(t); + write(dir, [ + '#Minecraft server properties', + '#Mon Sep 28 12:00:00 BST 2026', + 'level-type=minecraft\\:flat', + 'motd=A \\= sign', + 'max-players = 7', + 'spawn-protection =16', + 'level-seed=', + '', + 'not a property', + 'generator-settings={"a"\\:1}', + '' + ].join('\r\n')); + assert.deepEqual(parseServerProperties(dir), { + 'level-type': 'minecraft:flat', + 'motd': 'A = sign', + 'max-players': '7', + 'spawn-protection': '16', + 'level-seed': '', + 'generator-settings': '{"a":1}' + }); +}); + +test('parse returns an empty object when there is no file', (t) => { + assert.deepEqual(parseServerProperties(tempDir(t)), {}); +}); + +test('a new file has the defaults, with first-start settings after the seed', (t) => { + const dir = tempDir(t); + writeServerProperties(dir, {}, { version: '1.21.1' }); + const content = read(dir); + assert.match(content, /^#Minecraft server properties\n#Generated by Craftbox on /); + const keys = keysOf(content); + const seed = keys.indexOf('level-seed'); + assert.deepEqual(keys.slice(seed, seed + 6), [ + 'level-seed', 'hardcore', 'level-type', 'generator-settings', 'initial-enabled-packs', 'initial-disabled-packs' + ]); + const props = parseServerProperties(dir); + assert.equal(props.difficulty, 'normal'); + assert.equal(props.gamemode, 'survival'); + assert.equal(props['online-mode'], 'true'); + assert.equal(props['level-type'], 'minecraft:normal'); + assert.equal(props['generator-settings'], '{}'); + for (const key of Object.keys(DEFAULT_PROPERTIES)) assert.ok(key in props, `${key} missing`); +}); + +test('first-start settings follow the version', (t) => { + const cases = { + '1.12.2': { 'level-type': 'default', 'generator-settings': '', packs: false }, + '1.16.5': { 'level-type': 'default', 'generator-settings': '', packs: false }, + '1.18.2': { 'level-type': 'default', 'generator-settings': '{}', packs: false }, + '1.19.2': { 'level-type': 'minecraft:normal', 'generator-settings': '{}', packs: false }, + '1.19.3': { 'level-type': 'minecraft:normal', 'generator-settings': '{}', packs: true }, + '26.1': { 'level-type': 'minecraft:normal', 'generator-settings': '{}', packs: true }, + '': { 'level-type': 'minecraft:normal', 'generator-settings': '{}', packs: true } // a custom jar + }; + for (const [version, want] of Object.entries(cases)) { + const dir = tempDir(t); + writeServerProperties(dir, {}, { version }); + const props = parseServerProperties(dir); + assert.equal(props['level-type'], want['level-type'], `${version} level-type`); + assert.equal(props['generator-settings'], want['generator-settings'], `${version} generator-settings`); + assert.equal(props.hardcore, 'false', `${version} hardcore`); + assert.equal('initial-enabled-packs' in props, want.packs, `${version} initial-enabled-packs`); + } +}); + +test('overrides take camelCase and raw keys', (t) => { + const dir = tempDir(t); + writeServerProperties(dir, { + serverPort: 25570, maxPlayers: 5, motd: 'Hi', gamemode: 'creative', difficulty: 'hard', + viewDistance: 6, levelSeed: '1234', levelType: 'minecraft:flat', + 'enable-rcon': true, 'rcon.password': 'secret', notAProperty: 'x' + }, { version: '1.21.1' }); + const props = parseServerProperties(dir); + assert.equal(props['server-port'], '25570'); + assert.equal(props['max-players'], '5'); + assert.equal(props.motd, 'Hi'); + assert.equal(props.gamemode, 'creative'); + assert.equal(props.difficulty, 'hard'); + assert.equal(props['view-distance'], '6'); + assert.equal(props['level-seed'], '1234'); + assert.equal(props['level-type'], 'minecraft:flat'); + assert.equal(props['enable-rcon'], 'true'); + assert.equal(props['rcon.password'], 'secret'); + assert.ok(!('notAProperty' in props)); +}); + +test('merging keeps an existing file line for line and only fills the gaps', (t) => { + const dir = tempDir(t); + write(dir, [ + '#From a modpack', + 'level-type=minecraft\\:amplified', + 'motd=\\u00A7aHello', + 'allow-flight=true', + 'server-port=1234', + '' + ].join('\n')); + writeServerProperties(dir, { serverPort: 25570 }, { mergeExisting: true, version: '1.21.1' }); + const lines = read(dir).split('\n'); + assert.deepEqual(lines.slice(0, 5), [ + '#From a modpack', + 'level-type=minecraft\\:amplified', + 'motd=\\u00A7aHello', + 'allow-flight=true', + 'server-port=25570' + ]); + assert.equal(lines.at(-1), '', 'trailing newline kept'); + const props = parseServerProperties(dir); + assert.equal(props['online-mode'], 'true', 'missing default filled in'); + assert.equal(props.hardcore, 'false', 'missing first-start setting filled in'); + assert.equal(lines.filter((l) => l.startsWith('level-type=')).length, 1, 'level-type duplicated'); +}); + +test('merging with no existing file writes a fresh one', (t) => { + const dir = tempDir(t); + writeServerProperties(dir, { serverPort: 25571 }, { mergeExisting: true, version: '1.21.1' }); + assert.match(read(dir), /^#Minecraft server properties/); + assert.equal(parseServerProperties(dir)['server-port'], '25571'); +}); + +test('update edits in place, keeps comments and appends new keys', (t) => { + const dir = tempDir(t); + write(dir, '#comment\na=1\nb = 2\n\nc=3\n'); + updateServerProperties(dir, { b: 20, d: 4 }); + assert.equal(read(dir), '#comment\na=1\nb=20\n\nc=3\nd=4\n'); +}); + +test('update keeps a file without a trailing newline that way', (t) => { + const dir = tempDir(t); + write(dir, 'a=1'); + updateServerProperties(dir, { b: 2 }); + assert.equal(read(dir), 'a=1\nb=2'); +}); + +test('update does nothing when there is no file', (t) => { + const dir = tempDir(t); + updateServerProperties(dir, { a: 1 }); + assert.equal(fs.existsSync(path.join(dir, 'server.properties')), false); +}); + +test('writeEula accepts the EULA', (t) => { + const dir = tempDir(t); + writeEula(dir); + const content = fs.readFileSync(path.join(dir, 'eula.txt'), 'utf8'); + assert.match(content, /^eula=true$/m); + assert.match(content, /^#.*aka\.ms\/MinecraftEULA/m); +}); + +// The Properties page shows PROPERTY_META's defaults; a new server gets +// DEFAULT_PROPERTIES. A default changed in one and not the other shows the +// user a "default" the server never had. +test('the Properties page defaults agree with a new server', () => { + const disagree = []; + for (const [key, value] of Object.entries(DEFAULT_PROPERTIES)) { + const meta = PROPERTY_META[key]; + if (meta && String(meta.default) !== String(value)) disagree.push(`${key}: ${value} vs ${meta.default}`); + } + assert.deepEqual(disagree, []); +}); + +test('every Properties page default is valid for its own field', () => { + const invalid = []; + for (const [key, meta] of Object.entries(PROPERTY_META)) { + if (meta.type === 'enum' && !meta.options.some((o) => o.value === meta.default)) invalid.push(key); + if (meta.type === 'boolean' && !['true', 'false'].includes(meta.default)) invalid.push(key); + if (meta.type === 'number') { + const n = Number(meta.default); + if (!Number.isFinite(n) || n < meta.min || n > meta.max) invalid.push(key); + } + } + assert.deepEqual(invalid, []); +}); diff --git a/test/unit/versions.test.js b/test/unit/versions.test.js new file mode 100644 index 0000000..5b745c9 --- /dev/null +++ b/test/unit/versions.test.js @@ -0,0 +1,102 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { MC_VERSION_RE, isReleaseVersion, isAtLeast } = require('../../src/utils/mcVersion'); +const { classifyMcId, pickLatestBuild, compareBuilds } = require('../../src/mc/serverTypes/_channels'); +const { worldTypesFor, worldTypeFor, firstStartProperties } = require('../../src/mc/worldTypes'); + +test('MC_VERSION_RE accepts every id form Mojang uses', () => { + for (const v of ['1.21.4', '1.21', '26.1', '1.21.5-pre1', '1.21.5-rc2', '25w03a', '23w13a_or_b', '1.14 Pre-Release 5', 'b1.7.3']) { + assert.ok(MC_VERSION_RE.test(v), v); + } +}); + +test('MC_VERSION_RE refuses anything path- or URL-breaking', () => { + for (const v of ['', '../1.21', '1.21/evil', '1.21?x=1', '1.21#x', ' 1.21', '-1.21', '.1.21', 'a'.repeat(65), '1.21\n']) { + assert.ok(!MC_VERSION_RE.test(v), JSON.stringify(v)); + } +}); + +test('isReleaseVersion matches plain releases only', () => { + for (const v of ['1.21', '1.21.4', '26.1', '26.1.2']) assert.ok(isReleaseVersion(v), v); + for (const v of ['1.21.5-pre1', '25w03a', '1.14 Pre-Release 5', '', undefined, null, '1']) assert.ok(!isReleaseVersion(v), String(v)); +}); + +test('isAtLeast compares releases numerically', () => { + assert.equal(isAtLeast('1.19.3', '1.19.3', '22w42a'), true); + assert.equal(isAtLeast('1.19.2', '1.19.3', '22w42a'), false); + assert.equal(isAtLeast('1.20', '1.19.3', '22w42a'), true); + assert.equal(isAtLeast('1.19', '1.19.0', '22w11a'), true); + assert.equal(isAtLeast('1.9', '1.19', '22w11a'), false, '1.9 is not after 1.19'); + assert.equal(isAtLeast('1.10', '1.9', '15w31a'), true, '1.10 is after 1.9'); + assert.equal(isAtLeast('26.1', '1.19.3', '22w42a'), true); +}); + +test('isAtLeast places snapshots, pre-releases and unknown forms', () => { + assert.equal(isAtLeast('22w42a', '1.19.3', '22w42a'), true); + assert.equal(isAtLeast('22w45a', '1.19.3', '22w42a'), true); + assert.equal(isAtLeast('22w40a', '1.19.3', '22w42a'), false); + assert.equal(isAtLeast('26w14a', '1.19.3', '22w42a'), true); + assert.equal(isAtLeast('1.19.3-pre1', '1.19.3', '22w42a'), true, 'a pre-release counts as its release'); + assert.equal(isAtLeast('1.19.3-rc2', '1.19.3', '22w42a'), true); + assert.equal(isAtLeast('1.19.2-pre1', '1.19.3', '22w42a'), false); + assert.equal(isAtLeast('1.14 Pre-Release 5', '1.14', '18w43a'), true); + assert.equal(isAtLeast('', '1.19.3', '22w42a'), true, 'a custom jar counts as current'); + assert.equal(isAtLeast('something-odd', '1.19.3', '22w42a'), true); +}); + +test('classifyMcId names the channel of a non-release id', () => { + assert.equal(classifyMcId('1.21.5-pre1'), 'pre-release'); + assert.equal(classifyMcId('1.14 Pre-Release 5'), 'pre-release'); + assert.equal(classifyMcId('1.21.5-rc2'), 'rc'); + assert.equal(classifyMcId('25w03a'), 'snapshot'); +}); + +test('pickLatestBuild takes the head of a newest-first list', () => { + assert.equal(pickLatestBuild([]), null); + assert.equal(pickLatestBuild(null), null); + assert.deepEqual(pickLatestBuild([{ build: 3 }, { build: 2 }]), { build: 3 }); +}); + +test('compareBuilds orders numbers and dotted versions', () => { + const newer = (a, b) => assert.ok(compareBuilds(a, b) > 0 && compareBuilds(b, a) < 0, `${a} > ${b}`); + newer(100, 99); + newer('100', '99'); + newer('21.1.100', '21.1.95'); + newer('0.16.10', '0.16.9'); + newer('47.4.23', '47.4.10'); + newer('21.9.16', '21.9.16-beta'); + newer('21.1.1', '21.1'); + newer('26.1.2.112', '26.1.1.3'); + assert.equal(compareBuilds('21.1.95', '21.1.95'), 0); + assert.equal(compareBuilds(null, 5), 0); + assert.equal(compareBuilds(5, undefined), 0); +}); + +test('world types use presets from 1.19 and legacy names before', () => { + assert.equal(worldTypesFor('1.18.2')[0].value, 'default'); + assert.equal(worldTypesFor('22w10a')[0].value, 'default'); + assert.equal(worldTypesFor('1.19')[0].value, 'minecraft:normal'); + assert.equal(worldTypesFor('22w11a')[0].value, 'minecraft:normal'); + assert.equal(worldTypesFor('26.1')[0].value, 'minecraft:normal'); +}); + +test('worldTypeFor translates a type into the spelling a version reads', () => { + assert.equal(worldTypeFor('flat', '1.21.1'), 'minecraft:flat'); + assert.equal(worldTypeFor('minecraft:flat', '1.18.2'), 'flat'); + assert.equal(worldTypeFor('default', '1.21.1'), 'minecraft:normal'); + assert.equal(worldTypeFor('LARGEBIOMES', '1.16.5'), 'largeBiomes'); + assert.equal(worldTypeFor('DEFAULT', '1.12.2'), 'default'); + assert.equal(worldTypeFor('minecraft:single_biome_surface', '1.16.5'), null, 'no Single Biome before 1.19'); + assert.equal(worldTypeFor('bogus', '1.21.1'), null); + assert.equal(worldTypeFor('', '1.21.1'), null); +}); + +test('firstStartProperties matches what each version writes itself', () => { + assert.deepEqual(firstStartProperties('1.16.5'), { hardcore: false, 'level-type': 'default', 'generator-settings': '' }); + assert.deepEqual(firstStartProperties('1.18.2'), { hardcore: false, 'level-type': 'default', 'generator-settings': '{}' }); + assert.deepEqual(firstStartProperties('1.19.2'), { hardcore: false, 'level-type': 'minecraft:normal', 'generator-settings': '{}' }); + assert.deepEqual(firstStartProperties('1.19.3'), { + hardcore: false, 'level-type': 'minecraft:normal', 'generator-settings': '{}', + 'initial-enabled-packs': 'vanilla', 'initial-disabled-packs': '' + }); +});