Merge pull request #1154 from bbassie/fix/deleted-exports

Handle deleted export files
This commit is contained in:
Bastiaan authored and GitHub committed 2026-11-01 23:10:27 +00:00
1 parent d7d1859aa0
commit 1b87497e15
7 files changed
+178 -25

No files matched your search

+37
View File
@@ -198,6 +198,43 @@ describe("controller/src/Controller", function() {
});
});
});
describe(".validateExports()", function() {
let dir;
before(async function() {
dir = await fs.mkdtemp(path.join(os.tmpdir(), "clusterio-exports-"));
await fs.writeFile(path.join(dir, "locale.abc.json"), "[]");
await fs.writeFile(path.join(dir, "metadata.abc.json"), "{}");
});
after(async function() {
await fs.rm(dir, { recursive: true, force: true });
});
it("should drop export manifests with missing or invalid files", async function() {
const provider = new lib.MemoryDatastoreProvider();
const modPacks = new lib.SubscribableDatastore(provider, new Map());
const packs = [
{ id: 1, assets: { locale: "locale.abc.json", metadata: "metadata.abc.json" } },
{ id: 2, assets: { locale: "locale.abc.json", metadata: "metadata.def.json" } },
{ id: 3, assets: { locale: "../locale.abc.json" } },
{ id: 4 },
].map(({ id, assets }) => {
const modPack = ModPack.fromJSON({ id, name: `pack ${id}` });
if (assets) {
modPack.exportManifest = new lib.ExportManifest(assets);
}
return modPack;
});
modPacks.setMany(packs);
const updates = [];
modPacks.on("update", updated => updates.push(...updated));
await Controller.validateExports(modPacks, dir);
assert.deepEqual(modPacks.get(1).exportManifest.assets, packs[0].exportManifest.assets);
assert.equal(modPacks.get(2).exportManifest, undefined);
assert.equal(modPacks.get(3).exportManifest, undefined);
assert.equal(modPacks.get(4).exportManifest, undefined);
assert.deepEqual(updates.map(modPack => modPack.id), [2, 3]);
});
});
describe(".loadJsonObject()", function() {
let dir;
before(async function() {
+52 -12
View File
@@ -1,11 +1,15 @@
import assert from "node:assert/strict";
import events from "node:events";
import fs from "node:fs/promises";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import jwt from "jsonwebtoken";
import express from "express";
import { SubscribableDatastore, wait, expositionContentType } from "@clusterio/lib";
import * as lib from "@clusterio/lib";
import { Controller, HostPairingManager, HostRecord } from "@clusterio/controller";
import * as routes from "@clusterio/controller/dist/node/src/routes.js";
import * as mock from "../mock.js";
@@ -51,7 +55,7 @@ describe("controller/src/routes", function() {
stream = await routes.createProxyStream(controller.app);
responses = await Promise.all([
(async function() {
await wait(100);
await lib.wait(100);
return await fetch(`${endpoint}/${stream.id}`, {
method: "PUT", body: "test content",
});
@@ -67,7 +71,7 @@ describe("controller/src/routes", function() {
method: "PUT", body: "test content",
}),
(async function() {
await wait(100);
await lib.wait(100);
return await fetch(`${endpoint}/${stream.id}`);
}()),
]);
@@ -114,7 +118,7 @@ describe("controller/src/routes", function() {
let response = await fetch(endpoint, { headers });
assert.equal(response.status, 200);
const contentType = response.headers.get("content-type");
for (const part of expositionContentType.split("; ")) {
for (const part of lib.expositionContentType.split("; ")) {
assert(contentType.includes(part), `expected ${part} in content type ${contentType}`);
}
assert(/^# HELP /m.test(await response.text()), "expected exposition text");
@@ -124,7 +128,7 @@ describe("controller/src/routes", function() {
let calls = 0;
controller.hooks.metrics.attach("test", async () => {
calls += 1;
await wait(100);
await lib.wait(100);
});
const headers = { "X-Access-Token": testToken };
let responses = await Promise.all([
@@ -141,6 +145,42 @@ describe("controller/src/routes", function() {
assert.equal(calls, 2);
});
});
describe("missingExportAsset()", function() {
let dir;
beforeEach(async function() {
dir = await fs.mkdtemp(path.join(os.tmpdir(), "clusterio-static-"));
await fs.writeFile(path.join(dir, "locale.abc.json"), "[]");
const provider = new lib.MemoryDatastoreProvider();
controller.modPacks = new lib.SubscribableDatastore(provider, new Map());
const modPack = lib.ModPack.fromJSON({ id: 1, name: "test" });
modPack.exportManifest = new lib.ExportManifest({
locale: "locale.abc.json", metadata: "metadata.abc.json",
});
controller.modPacks.set(modPack);
controller.app.use("/static", express.static(dir));
controller.app.use("/static", routes.missingExportAsset);
});
afterEach(async function() {
await fs.rm(dir, { recursive: true, force: true });
});
it("should serve export files that exist", async function() {
const response = await fetch(`http://localhost:${port}/static/locale.abc.json`);
assert.equal(response.status, 200);
assert(controller.modPacks.get(1).exportManifest);
});
it("should drop the export and explain when a file is missing", async function() {
const response = await fetch(`http://localhost:${port}/static/metadata.abc.json`);
assert.equal(response.status, 404);
assert.match(await response.text(), /metadata\.abc\.json is missing/);
assert.equal(controller.modPacks.get(1).exportManifest, undefined);
});
it("should pass through files not in an export", async function() {
const response = await fetch(`http://localhost:${port}/static/other.js`);
assert.equal(response.status, 404);
assert.doesNotMatch(await response.text(), /export-data/);
assert(controller.modPacks.get(1).exportManifest);
});
});
describe("stripStaticPrefix()", function() {
it("should remove a leading static/", function() {
assert.equal(routes.stripStaticPrefix("static/main.abc.js"), "main.abc.js");
@@ -218,7 +258,7 @@ describe("controller/src/routes", function() {
beforeEach(function() {
endpoint = `http://localhost:${port}/api/host-pairing`;
// The mock controller lacks what pairing needs, use the real implementations.
controller.hosts = new SubscribableDatastore();
controller.hosts = new lib.SubscribableDatastore();
controller.hostPairing = new HostPairingManager(controller);
controller.generateHostToken = Controller.prototype.generateHostToken;
controller.verifyHostToken = Controller.prototype.verifyHostToken;
@@ -227,8 +267,8 @@ describe("controller/src/routes", function() {
playerToken = controller.users.signUserToken(controller.users.getByName("player"));
});
function post(path, body, headers = {}) {
return fetch(`${endpoint}${path}`, {
function post(urlPath, body, headers = {}) {
return fetch(`${endpoint}${urlPath}`, {
method: "POST",
headers: { "Content-Type": "application/json", ...headers },
body: typeof body === "string" || body === undefined ? body : JSON.stringify(body),
@@ -453,7 +493,7 @@ describe("controller/src/routes", function() {
it("should not be accepted in the URL", async function() {
const { requestId } = await createRequest();
const id = encodeURIComponent(requestId);
for (const [method, path] of [
for (const [method, urlPath] of [
["GET", `/poll?requestId=${id}`],
["GET", `/poll/${id}`],
["POST", `/poll/${id}`],
@@ -461,8 +501,8 @@ describe("controller/src/routes", function() {
["GET", `/${id}`],
["POST", `/cancel/${id}`],
]) {
const response = await fetch(`${endpoint}${path}`, { method });
assert.equal(response.status, 404, `${method} ${path}`);
const response = await fetch(`${endpoint}${urlPath}`, { method });
assert.equal(response.status, 404, `${method} ${urlPath}`);
}
await assertResponse(await post("/poll", { requestId }), 202, { status: "pending" });
});