Skip to content
Open
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
87 changes: 87 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"@types/lodash.clonedeep": "^4.5.3",
"@types/mocha": "^8.0.0",
"@types/node": "~22.14.1",
"@types/sinon": "^21.0.1",
"@types/tv4": "^1.2.29",
"@types/yauzl": "^2.10.3",
"@types/yazl": "^2.4.1",
Expand All @@ -48,6 +49,7 @@
"eslint-plugin-prettier": "^5.5.3",
"husky": "^4.2.5",
"mocha": "^8.0.1",
"sinon": "^22.0.0",
"ts-node": "^9.1.1"
},
"dependencies": {
Expand Down
53 changes: 36 additions & 17 deletions src/compiler/AppsEngineValidator.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
import * as vm from "vm";
import path from "path";

import type { AppInterface } from "@rocket.chat/apps-engine/definition/metadata";
import type { ICompilerResult } from "../definition";
import type { IPermission } from "../definition/IPermission";
import type { IAppPermissions } from "../misc/getAvailablePermissions";
import { getAvailablePermissions } from "../misc/getAvailablePermissions";
import { Utilities } from "../misc/Utilities";
import logger from "../misc/logger";

export class AppsEngineValidator {
constructor(private readonly appSourceRequire: NodeRequire) {}
private readonly safeAppSourceRequire: (id: string) => any;

constructor(private readonly appSourceRequire: NodeJS.Require) {
this.safeAppSourceRequire = (id: string) => {
try {
return appSourceRequire(id);
} catch {
// It's ok not to find it
}
};
}

public validateAppPermissionsSchema(permissions: Array<IPermission>): void {
if (!permissions) {
Expand All @@ -20,17 +33,25 @@ export class AppsEngineValidator {
);
}

const permissionsRequire = this.appSourceRequire(
"@rocket.chat/apps-engine/server/permissions/AppPermissions",
);

if (!permissionsRequire?.AppPermissions) {
const {
AppPermissions,
}: { AppPermissions: IAppPermissions | undefined } =
this.safeAppSourceRequire(
"@rocket.chat/apps-engine/server/permissions/AppPermissions",
) ||
this.safeAppSourceRequire(
"@rocket.chat/apps-engine/definition/metadata/AppPermissions",
) ||
{};

if (!AppPermissions) {
logger.warn(
"Couldn't find permissions in @rocket.chat/apps-engine version. App's permissions won't be validated",
);
return;
}

const availablePermissions = getAvailablePermissions(
permissionsRequire.AppPermissions,
);
const availablePermissions = getAvailablePermissions(AppPermissions);

permissions.forEach((permission) => {
if (permission && !availablePermissions.includes(permission.name)) {
Expand All @@ -42,17 +63,15 @@ export class AppsEngineValidator {
}

public isValidAppInterface(interfaceName: string): boolean {
let { AppInterface } = this.appSourceRequire(
"@rocket.chat/apps-engine/definition/metadata",
);

if (!AppInterface) {
AppInterface = this.appSourceRequire(
const { AppInterface }: { AppInterface: AppInterface | undefined } =
this.safeAppSourceRequire(
"@rocket.chat/apps-engine/definition/metadata",
) ||
this.safeAppSourceRequire(
"@rocket.chat/apps-engine/server/compiler/AppImplements",
);
}

return interfaceName in AppInterface;
return !!AppInterface[interfaceName as keyof AppInterface];
}

public resolveAppDependencyPath(module: string): string | undefined {
Expand Down
171 changes: 171 additions & 0 deletions tests/AppsEngineValidator.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { expect } from "chai";
import { describe, it, beforeEach, afterEach } from "mocha";
import sinon from "sinon";

import { AppsEngineValidator } from "../src/compiler/AppsEngineValidator";
import logger from "../src/misc/logger";

const OLD_PERMISSIONS_PATH =
"@rocket.chat/apps-engine/server/permissions/AppPermissions";
const NEW_PERMISSIONS_PATH =
"@rocket.chat/apps-engine/definition/metadata/AppPermissions";
const OLD_INTERFACE_PATH = "@rocket.chat/apps-engine/definition/metadata";
const NEW_INTERFACE_PATH =
"@rocket.chat/apps-engine/server/compiler/AppImplements";

const mockAppPermissions = {
network: {
write: { name: "networking.write" },
read: { name: "networking.read" },
},
env: {
read: { name: "env.read" },
},
};

function makeRequire(moduleMap: Record<string, any>): NodeJS.Require {
const fn = (id: string) => {
if (id in moduleMap) {
return moduleMap[id];
}
throw new Error(`Cannot find module '${id}'`);
};
fn.resolve = () => {
throw new Error("not implemented");
};
fn.cache = {};
fn.extensions = {};
fn.main = undefined;
return fn as unknown as NodeJS.Require;
}

describe("AppsEngineValidator", () => {
let warnStub: sinon.SinonStub;

beforeEach(() => {
warnStub = sinon.stub(logger, "warn");
});

afterEach(() => {
sinon.restore();
});

describe("validateAppPermissionsSchema", () => {
it("returns early when permissions is falsy", () => {
const validator = new AppsEngineValidator(makeRequire({}));
expect(() =>
validator.validateAppPermissionsSchema(null as any),
).not.to.throw();
});

it("throws when permissions is not an array", () => {
const validator = new AppsEngineValidator(makeRequire({}));
expect(() =>
validator.validateAppPermissionsSchema({} as any),
).to.throw("Invalid permission definition");
});

it("logs a warning and skips validation when neither permissions module path resolves", () => {
const validator = new AppsEngineValidator(makeRequire({}));
validator.validateAppPermissionsSchema([
{ name: "networking.write" },
]);
expect(warnStub.calledOnce).to.be.true;
expect(warnStub.firstCall.args[0]).to.include(
"Couldn't find permissions in @rocket.chat/apps-engine version",
);
});

it("validates permissions using the old module path", () => {
const require = makeRequire({
[OLD_PERMISSIONS_PATH]: { AppPermissions: mockAppPermissions },
});
const validator = new AppsEngineValidator(require);
expect(() =>
validator.validateAppPermissionsSchema([
{ name: "networking.write" },
]),
).not.to.throw();
});

it("falls back to new module path when old path is not found", () => {
const require = makeRequire({
[NEW_PERMISSIONS_PATH]: { AppPermissions: mockAppPermissions },
});
const validator = new AppsEngineValidator(require);
expect(() =>
validator.validateAppPermissionsSchema([{ name: "env.read" }]),
).not.to.throw();
});

it("throws for an invalid permission name", () => {
const require = makeRequire({
[OLD_PERMISSIONS_PATH]: { AppPermissions: mockAppPermissions },
});
const validator = new AppsEngineValidator(require);
expect(() =>
validator.validateAppPermissionsSchema([
{ name: "not.a.real.permission" },
]),
).to.throw('Invalid permission "not.a.real.permission"');
});

it("skips null/undefined entries in the permissions array", () => {
const require = makeRequire({
[OLD_PERMISSIONS_PATH]: { AppPermissions: mockAppPermissions },
});
const validator = new AppsEngineValidator(require);
expect(() =>
validator.validateAppPermissionsSchema([
null as any,
undefined as any,
{ name: "networking.write" },
]),
).not.to.throw();
});
});

describe("isValidAppInterface", () => {
const mockAppInterface = {
IPreMessageSentPrevent: "IPreMessageSentPrevent",
IPostMessageSent: "IPostMessageSent",
};

it("returns true for a known interface using the primary module path", () => {
const require = makeRequire({
[OLD_INTERFACE_PATH]: { AppInterface: mockAppInterface },
});
const validator = new AppsEngineValidator(require);
expect(validator.isValidAppInterface("IPreMessageSentPrevent")).to
.be.true;
});

it("returns false for an unknown interface", () => {
const require = makeRequire({
[OLD_INTERFACE_PATH]: { AppInterface: mockAppInterface },
});
const validator = new AppsEngineValidator(require);
expect(validator.isValidAppInterface("IDoesNotExist")).to.be.false;
});

it("falls back to the legacy module path when primary path is not found", () => {
const require = makeRequire({
[NEW_INTERFACE_PATH]: { AppInterface: mockAppInterface },
});
const validator = new AppsEngineValidator(require);
expect(validator.isValidAppInterface("IPostMessageSent")).to.be
.true;
});

it("returns false for falsy interface value (not just key presence)", () => {
const require = makeRequire({
[OLD_INTERFACE_PATH]: {
AppInterface: { IFalsyInterface: "" },
},
});
const validator = new AppsEngineValidator(require);
expect(validator.isValidAppInterface("IFalsyInterface")).to.be
.false;
});
});
});
Loading