Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions src/file-manager/file-manager.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { BadRequestException } from "@nestjs/common";
import { FileManagerService } from "./file-manager.service";

// Nest builds an HttpException's message from the response only when it is a
// string; anything else falls back to the class name, so the operator is told
// "Bad Request Exception" and nothing about what they did wrong.
describe("FileManagerService.connectorErrorMessage", () => {
const message = (error: unknown) =>
new BadRequestException(FileManagerService.connectorErrorMessage(error))
.message;

it("flattens the array the connector's ValidationPipe returns", () => {
expect(
FileManagerService.connectorErrorMessage({
statusCode: 400,
message: [
"destPath must be a string",
"sourcePath should not be empty",
],
error: "Bad Request",
}),
).toBe("destPath must be a string, sourcePath should not be empty");
});

it("passes a plain string through", () => {
expect(
FileManagerService.connectorErrorMessage({
message: "Destination already exists: addons",
}),
).toBe("Destination already exists: addons");
});

// A proxy in front of the connector answers in its own shape, and an object
// reaching BadRequestException reads as "Bad Request Exception".
it("flattens a message that is not a string at all", () => {
expect(message({ message: { error: "path traversal detected" } })).toBe(
'{"error":"path traversal detected"}',
);
expect(message({ message: 400 })).toBe("400");
});

it("gives an empty string when there is no message to report", () => {
expect(FileManagerService.connectorErrorMessage({})).toBe("");
expect(FileManagerService.connectorErrorMessage(undefined)).toBe("");
expect(FileManagerService.connectorErrorMessage({ message: null })).toBe(
"",
);
});

it("survives the round trip into an exception the operator reads", () => {
expect(message({ message: ["destPath must be a string"] })).toBe(
"destPath must be a string",
);
});
});
79 changes: 48 additions & 31 deletions src/file-manager/file-manager.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,34 +62,67 @@ export class FileManagerService {
return `http://${nodeIP}:8585/file-operations/${endpoint}`;
}

// The connector's validation errors come back as an array of strings, and an
// HttpException built from anything but a string reports itself as "Bad
// Request Exception" -- the operator is told the request failed and nothing
// about why. The body is whatever came off the wire, so nothing here can
// assume the shape. Public because GamePluginsService reaches the same
// connector behind the same ValidationPipe.
public static connectorErrorMessage(error: unknown): string {
const message = (error as { message?: unknown })?.message;

if (Array.isArray(message)) {
return message.join(", ");
}

if (typeof message === "string") {
return message;
}

if (message === undefined || message === null) {
return "";
}

return JSON.stringify(message);
}

private async requestNodeConnector(
nodeIP: string,
endpoint: string,
options: RequestInit = {},
): Promise<any> {
const url = this.getNodeConnectorURL(nodeIP, endpoint);
// fetch sets its own multipart Content-Type, boundary and all; naming it
// here would leave the connector unable to parse the upload.
const isFormData = options.body instanceof FormData;

let response: Response;

try {
const response = await fetch(url, {
response = await fetch(url, {
...options,
headers: {
"Content-Type": "application/json",
...(isFormData ? {} : { "Content-Type": "application/json" }),
...options.headers,
},
});

if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new BadRequestException(
error.message || `Node connector error: ${response.statusText}`,
);
}

return await response.json();
} catch (error) {
this.logger.error(`Error calling node connector at ${url}`, error);
throw error;
}

// Thrown outside the catch above: a rejected file operation is the operator
// mistyping a path, not the node being unreachable, and logging it at error
// level with a stack buries the transport failures that are.
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new BadRequestException(
FileManagerService.connectorErrorMessage(error) ||
`Node connector error: ${response.statusText}`,
);
}

return await response.json();
}

async listFiles(
Expand Down Expand Up @@ -246,25 +279,9 @@ export class FileManagerService {
formData.append("basePath", basePath);
formData.append("filePath", filePath);

const url = this.getNodeConnectorURL(nodeIP, "upload");

try {
const response = await fetch(url, {
method: "POST",
body: formData,
});

if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new BadRequestException(
error.message || `Upload failed: ${response.statusText}`,
);
}

return await response.json();
} catch (error) {
this.logger.error(`Error uploading file to ${url}`, error);
throw error;
}
return await this.requestNodeConnector(nodeIP, "upload", {
method: "POST",
body: formData,
});
}
}
8 changes: 4 additions & 4 deletions src/game-plugins/game-plugins.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { InjectQueue } from "@nestjs/bullmq";
import { Job, Queue } from "bullmq";
import { HasuraService } from "../hasura/hasura.service";
import { PostgresService } from "../postgres/postgres.service";
import { FileManagerService } from "../file-manager/file-manager.service";
import { PluginRuntimeService } from "../plugin-runtime/plugin-runtime.service";
import { CacheService } from "../cache/cache.service";
import { SystemSettingName } from "../system/enums/SystemSettingName";
Expand Down Expand Up @@ -1476,11 +1477,10 @@ export class GamePluginsService {
});

if (!response.ok) {
const body = await response
.json()
.catch(() => ({}) as { message?: string });
const body = await response.json().catch(() => ({}));
throw new BadRequestException(
body.message || `node connector returned ${response.status}`,
FileManagerService.connectorErrorMessage(body) ||
`node connector returned ${response.status}`,
);
}

Expand Down
Loading