diff --git a/.eslintrc.js b/.eslintrc.js index c4fad7c14..c3f12378b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -6,6 +6,7 @@ module.exports = { ], extends: [ 'eslint:recommended', + 'plugin:mocha/recommended', 'plugin:@typescript-eslint/recommended', ], // eslint-config-preact needs a Jest version to be happy, even if Jest isn't used. @@ -18,7 +19,10 @@ module.exports = { "@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-unused-vars": "warn", "camelcase": ["error", { "properties": "never", "ignoreDestructuring": true }], - "no-console": "error" + "no-console": "error", + "mocha/no-exclusive-tests": "error", + // We do this everywhere. + "mocha/no-mocha-arrows": "off", }, env: { node: true, diff --git a/changelog.d/906.feature b/changelog.d/906.feature new file mode 100644 index 000000000..b977f362f --- /dev/null +++ b/changelog.d/906.feature @@ -0,0 +1 @@ +Add a maximum allowed payload size to RSS feeds. This prevents oversized feeds from potentially slowing down Hookshot. diff --git a/config.sample.yml b/config.sample.yml index 7182a9bc8..a002300a3 100644 --- a/config.sample.yml +++ b/config.sample.yml @@ -112,6 +112,9 @@ listeners: # pollConcurrency: 4 # pollIntervalSeconds: 600 # pollTimeoutSeconds: 30 +# maximumFeedSizeMB: +# # (Optional) The maximum response size of a feed on first load. Oversized responses will prevent a connection from being created. +# 25 #provisioning: # # (Optional) Provisioning API for integration managers diff --git a/package.json b/package.json index 1515ad958..6889dcc9f 100644 --- a/package.json +++ b/package.json @@ -102,7 +102,7 @@ "chai": "^4.3.4", "eslint": "^8.49.0", "eslint-config-preact": "^1.3.0", - "eslint-plugin-mocha": "^10.1.0", + "eslint-plugin-mocha": "^10.4.2", "homerunner-client": "^1.0.0", "jest": "^29.7.0", "mini.css": "^3.0.1", diff --git a/src/Connections/FeedConnection.ts b/src/Connections/FeedConnection.ts index 26f427f38..6482831a9 100644 --- a/src/Connections/FeedConnection.ts +++ b/src/Connections/FeedConnection.ts @@ -10,6 +10,7 @@ import { GetConnectionsResponseItem } from "../provisioning/api"; import { readFeed, sanitizeHtml } from "../libRs"; import UserAgent from "../UserAgent"; import { retry, retryMatrixErrorFilter } from "../PromiseUtil"; +import { BridgeConfigFeeds } from "../config/Config"; const log = new Logger("FeedConnection"); const md = new markdown({ html: true, @@ -64,7 +65,7 @@ export class FeedConnection extends BaseConnection implements IConnection { return new FeedConnection(roomId, event.stateKey, event.content, intent); } - static async validateUrl(url: string): Promise { + static async validateUrl(url: string, config: BridgeConfigFeeds): Promise { try { new URL(url); } catch (ex) { @@ -75,6 +76,7 @@ export class FeedConnection extends BaseConnection implements IConnection { await readFeed(url, { userAgent: UserAgent, pollTimeoutSeconds: VALIDATION_FETCH_TIMEOUT_S, + maximumFeedSizeMb: config.maximumFeedSizeMB, }); } catch (ex) { throw new ApiError(`Could not read feed from URL: ${ex.message}`, ErrCode.BadValue); @@ -113,7 +115,7 @@ export class FeedConnection extends BaseConnection implements IConnection { } const state = this.validateState(data); - await FeedConnection.validateUrl(state.url); + await FeedConnection.validateUrl(state.url, config.feeds); const connection = new FeedConnection(roomId, state.url, state, intent); await intent.underlyingClient.sendStateEvent(roomId, FeedConnection.CanonicalEventType, state.url, state); diff --git a/src/Connections/SetupConnection.ts b/src/Connections/SetupConnection.ts index 9f4893f3a..337840e8d 100644 --- a/src/Connections/SetupConnection.ts +++ b/src/Connections/SetupConnection.ts @@ -312,7 +312,7 @@ export class SetupConnection extends CommandConnection { // provisionConnection will check it again, but won't give us a nice CommandError on failure try { - await FeedConnection.validateUrl(url); + await FeedConnection.validateUrl(url, this.config.feeds); } catch (err: unknown) { log.debug(`Feed URL '${url}' failed validation: ${err}`); if (err instanceof ApiError) { diff --git a/src/config/Config.ts b/src/config/Config.ts index 374c295ab..a7ac88c00 100644 --- a/src/config/Config.ts +++ b/src/config/Config.ts @@ -251,6 +251,7 @@ export interface BridgeConfigFeedsYAML { pollIntervalSeconds?: number; pollConcurrency?: number; pollTimeoutSeconds?: number; + maximumFeedSizeMB?: number; } export class BridgeConfigFeeds { @@ -259,6 +260,9 @@ export class BridgeConfigFeeds { public pollTimeoutSeconds: number; public pollConcurrency: number; + @configKey("The maximum response size of a feed on first load. Oversized responses will prevent a connection from being created.", true) + public maximumFeedSizeMB: number; + constructor(yaml: BridgeConfigFeedsYAML) { this.enabled = yaml.enabled; this.pollConcurrency = yaml.pollConcurrency ?? 4; @@ -266,6 +270,14 @@ export class BridgeConfigFeeds { assert.strictEqual(typeof this.pollIntervalSeconds, "number"); this.pollTimeoutSeconds = yaml.pollTimeoutSeconds ?? 30; assert.strictEqual(typeof this.pollTimeoutSeconds, "number"); + this.maximumFeedSizeMB = yaml.maximumFeedSizeMB ?? 25; + assert.strictEqual(typeof this.maximumFeedSizeMB, "number"); + if (this.maximumFeedSizeMB < 1) { + throw new ConfigError('feeds.maximumFeedSizeMB', 'Must be at least 1MB or greater'); + } + if (!Number.isInteger(this.maximumFeedSizeMB)) { + throw new ConfigError('feeds.maximumFeedSizeMB', 'Must be a whole number'); + } } @hideKey() diff --git a/src/config/Defaults.ts b/src/config/Defaults.ts index 70522e8eb..996fb4e12 100644 --- a/src/config/Defaults.ts +++ b/src/config/Defaults.ts @@ -124,6 +124,7 @@ export const DefaultConfigRoot: BridgeConfigRoot = { pollIntervalSeconds: 600, pollTimeoutSeconds: 30, pollConcurrency: 4, + maximumFeedSizeMB: 25, }, provisioning: { secret: "!secretToken" diff --git a/src/feeds/FeedReader.ts b/src/feeds/FeedReader.ts index 699598ad6..f554eec2e 100644 --- a/src/feeds/FeedReader.ts +++ b/src/feeds/FeedReader.ts @@ -215,6 +215,7 @@ export class FeedReader { etag, lastModified, userAgent: UserAgent, + maximumFeedSizeMb: this.config.maximumFeedSizeMB, }); // Store any entity tags/cache times. diff --git a/src/feeds/parser.rs b/src/feeds/parser.rs index 631caee81..fd473005b 100644 --- a/src/feeds/parser.rs +++ b/src/feeds/parser.rs @@ -37,6 +37,7 @@ pub struct ReadFeedOptions { pub etag: Option, pub poll_timeout_seconds: i64, pub user_agent: String, + pub maximum_feed_size_mb: i64, } #[derive(Serialize, Debug, Deserialize)] @@ -199,25 +200,41 @@ pub async fn js_read_feed(url: String, options: ReadFeedOptions) -> Result { + // Pre-emptive check + let content_length = res.content_length().unwrap_or(0); + if content_length > max_content_size { + return Err(JsError::new(Status::Unknown, "Feed exceeded maximum size")); + } + let res_headers = res.headers().clone(); match res.status() { StatusCode::OK => match res.text().await { - Ok(body) => match js_parse_feed(body) { - Ok(feed) => Ok(FeedResult { - feed: Some(feed), - etag: res_headers - .get("ETag") - .map(|v| v.to_str().unwrap()) - .map(|v| v.to_string()), - last_modified: res_headers - .get("Last-Modified") - .map(|v| v.to_str().unwrap()) - .map(|v| v.to_string()), - }), - Err(err) => Err(err), - }, + Ok(body) => { + // Check if we only got the length after loading the response. + match body.len() as u64 <= max_content_size { + true => match js_parse_feed(body) { + Ok(feed) => Ok(FeedResult { + feed: Some(feed), + etag: res_headers + .get("ETag") + .map(|v| v.to_str().unwrap()) + .map(|v| v.to_string()), + last_modified: res_headers + .get("Last-Modified") + .map(|v| v.to_str().unwrap()) + .map(|v| v.to_string()), + }), + Err(err) => Err(err), + }, + false => { + Err(JsError::new(Status::Unknown, "Feed exceeded maximum size")) + } + } + } Err(err) => Err(JsError::new(Status::Unknown, err)), }, StatusCode::NOT_MODIFIED => Ok(FeedResult { diff --git a/tests/FeedReader.spec.ts b/tests/FeedReader.spec.ts index 00f397450..30762ec74 100644 --- a/tests/FeedReader.spec.ts +++ b/tests/FeedReader.spec.ts @@ -1,6 +1,6 @@ import { expect } from "chai"; import EventEmitter from "events"; -import { BridgeConfigFeeds } from "../src/config/Config"; +import { BridgeConfigFeeds, BridgeConfigFeedsYAML } from "../src/config/Config"; import { ConnectionManager } from "../src/ConnectionManager"; import { IConnection } from "../src/Connections"; import { FeedEntry, FeedReader } from "../src/feeds/FeedReader"; @@ -39,14 +39,15 @@ class MockMessageQueue extends EventEmitter implements MessageQueue { } } -async function constructFeedReader(feedResponse: () => {headers: Record, data: string}) { +async function constructFeedReader(feedResponse: () => {headers?: Record, data: string}, extraConfig?: Partial) { + const httpServer = await new Promise(resolve => { const srv = createServer((_req, res) => { - res.writeHead(200); const { headers, data } = feedResponse(); - Object.entries(headers).forEach(([key,value]) => { + Object.entries(headers ?? {}).forEach(([key,value]) => { res.setHeader(key, value); }); + res.writeHead(200); res.write(data); res.end(); }).listen(0, '127.0.0.1', () => { @@ -59,6 +60,7 @@ async function constructFeedReader(feedResponse: () => {headers: Record {headers: Record httpServer.close()); + + after(() => { + httpServer.close() + feedReader.stop(); + }); + return {config, cm, events, feedReader, feedUrl, httpServer, storage}; } @@ -94,12 +101,12 @@ describe("FeedReader", () => { })); await feedReader.pollFeed(feedUrl); - feedReader.stop(); expect(events).to.have.lengthOf(1); expect(events[0].data.feed.title).to.equal(null); expect(events[0].data.title).to.equal(null); }); + it("should handle RSS 2.0 feeds", async () => { const { events, feedReader, feedUrl } = await constructFeedReader(() => ({ headers: {}, data: ` @@ -127,7 +134,6 @@ describe("FeedReader", () => { })); await feedReader.pollFeed(feedUrl); - feedReader.stop(); expect(events).to.have.lengthOf(1); expect(events[0].data.feed.title).to.equal('RSS Title'); @@ -137,6 +143,7 @@ describe("FeedReader", () => { expect(events[0].data.link).to.equal('http://www.example.com/blog/post/1'); expect(events[0].data.pubdate).to.equal('Sun, 6 Sep 2009 16:20:00 +0000'); }); + it("should handle RSS feeds with a permalink url", async () => { const { events, feedReader, feedUrl } = await constructFeedReader(() => ({ headers: {}, data: ` @@ -163,7 +170,6 @@ describe("FeedReader", () => { })); await feedReader.pollFeed(feedUrl); - feedReader.stop(); expect(events).to.have.lengthOf(1); expect(events[0].data.feed.title).to.equal('RSS Title'); @@ -173,6 +179,7 @@ describe("FeedReader", () => { expect(events[0].data.link).to.equal('http://www.example.com/blog/post/1'); expect(events[0].data.pubdate).to.equal('Sun, 6 Sep 2009 16:20:00 +0000'); }); + it("should handle Atom feeds", async () => { const { events, feedReader, feedUrl } = await constructFeedReader(() => ({ headers: {}, data: ` @@ -203,7 +210,6 @@ describe("FeedReader", () => { })); await feedReader.pollFeed(feedUrl); - feedReader.stop(); expect(events).to.have.lengthOf(1); expect(events[0].data.feed.title).to.equal('Example Feed'); @@ -213,6 +219,7 @@ describe("FeedReader", () => { expect(events[0].data.link).to.equal('http://example.org/2003/12/13/atom03'); expect(events[0].data.pubdate).to.equal('Sat, 13 Dec 2003 18:30:02 +0000'); }); + it("should not duplicate feed entries", async () => { const { events, feedReader, feedUrl } = await constructFeedReader(() => ({ headers: {}, data: ` @@ -235,9 +242,9 @@ describe("FeedReader", () => { await feedReader.pollFeed(feedUrl); await feedReader.pollFeed(feedUrl); await feedReader.pollFeed(feedUrl); - feedReader.stop(); expect(events).to.have.lengthOf(1); }); + it("should always hash to the same value for Atom feeds", async () => { const expectedHash = ['md5:d41d8cd98f00b204e9800998ecf8427e']; const { feedReader, feedUrl, storage } = await constructFeedReader(() => ({ @@ -254,10 +261,10 @@ describe("FeedReader", () => { })); await feedReader.pollFeed(feedUrl); - feedReader.stop(); const items = await storage.hasSeenFeedGuids(feedUrl, ...expectedHash); expect(items).to.deep.equal(expectedHash); }); + it("should always hash to the same value for RSS feeds", async () => { const expectedHash = [ 'md5:98bafde155b931e656ad7c137cd7711e', // guid @@ -288,8 +295,53 @@ describe("FeedReader", () => { })); await feedReader.pollFeed(feedUrl); - feedReader.stop(); const items = await storage.hasSeenFeedGuids(feedUrl, ...expectedHash); expect(items).to.deep.equal(expectedHash); }); + + it("should fail to handle a feed which exceed the maximum size.", async () => { + // Create some data of the right length + const data = ` + + + + RSS Title + This is an example of an RSS feed + ${Array.from({length: 8000}).map((_, i) => ` + Example entry + http://www.example.com/blog/post/${i} + `).join('')} + + `; + const { feedReader, feedUrl } = await constructFeedReader(() => ({ + data, headers: { 'Content-Length': data.length.toString()} + }), { + maximumFeedSizeMB: 1 + }); + await feedReader.pollFeed(feedUrl); + expect(feedReader["feedsFailingParsing"]).to.contain(feedUrl); + }); + + it("should fail to handle a feed which exceed the maximum size which does NOT send a Content-Length.", async () => { + // Create some data of the right length + const data = ` + + + + RSS Title + This is an example of an RSS feed + ${Array.from({length: 8000}).map((_, i) => ` + Example entry + http://www.example.com/blog/post/${i} + `).join('')} + + `; + const { feedReader, feedUrl } = await constructFeedReader(() => ({ + data + }), { + maximumFeedSizeMB: 1 + }); + await feedReader.pollFeed(feedUrl); + expect(feedReader["feedsFailingParsing"]).to.contain(feedUrl); + }); }); diff --git a/tests/FormatUtilTest.ts b/tests/FormatUtilTest.ts index b8c35dc20..315f2ad3c 100644 --- a/tests/FormatUtilTest.ts +++ b/tests/FormatUtilTest.ts @@ -48,11 +48,13 @@ describe("FormatUtilTest", () => { "evilcorp/lab: A simple description", ); }); + it("should correctly formats a issue room name", () => { expect(FormatUtil.formatIssueRoomName(SIMPLE_ISSUE, SIMPLE_REPO)).to.equal( "evilcorp/lab#123: A simple title", ); }); + it("should correctly generate a partial body for a Github repo", () => { expect(FormatUtil.getPartialBodyForGithubRepo(SIMPLE_REPO)).to.deep.equal({ "external_url": "https://github.com/evilcorp/lab", @@ -63,6 +65,7 @@ describe("FormatUtilTest", () => { }, }); }); + it("should correctly generate a partial body for a Github issue", () => { expect(FormatUtil.getPartialBodyForGithubIssue(SIMPLE_REPO, SIMPLE_ISSUE)).to.deep.equal({ "external_url": "https://github.com/evilcorp/lab/issues/123", @@ -79,29 +82,34 @@ describe("FormatUtilTest", () => { }, }); }); + it("should correctly formats a room topic", () => { expect(FormatUtil.formatRoomTopic(SIMPLE_ISSUE)).to.equal( "Status: open | https://github.com/evilcorp/lab/issues/123", ); }); + it("should correctly format one simple label", () => { expect(FormatUtil.formatLabels([{name: "foo"}])).to.deep.equal({ plain: "foo", html: "foo" }); }); + it("should correctly format many simple labels", () => { expect(FormatUtil.formatLabels([{name: "foo"},{name: "bar"}])).to.deep.equal({ plain: "foo, bar", html: "foo bar" }); }); + it("should correctly format one detailed label", () => { expect(FormatUtil.formatLabels([{name: "foo", color: 'FFFFFF', description: 'My label'}])).to.deep.equal({ plain: "foo", html: "foo" }); }); + it("should correctly format many detailed labels", () => { expect(FormatUtil.formatLabels([ {name: "foo", color: 'FFFFFF', description: 'My label'}, @@ -112,6 +120,7 @@ describe("FormatUtilTest", () => { + "bar" },); }); + it("should correctly format a JIRA issue", () => { expect(FormatUtil.getPartialBodyForJiraIssue(SIMPLE_JIRA_ISSUE)).to.deep.equal({ "external_url": "http://example-api.url.com/browse/TEST-001", @@ -127,6 +136,7 @@ describe("FormatUtilTest", () => { }, }); }); + it("should hash an ID", () => { expect(FormatUtil.hashId("foobar")).to.equal('3858f62230ac3c915f300c664312c63f'); }); diff --git a/tests/HookFilter.ts b/tests/HookFilter.ts index f5e8821d1..16fa1423d 100644 --- a/tests/HookFilter.ts +++ b/tests/HookFilter.ts @@ -6,13 +6,16 @@ const ENABLED_SET = ['enabled-hook', 'enabled-but-ignored']; describe("HookFilter", () => { let filter: HookFilter; + beforeEach(() => { filter = new HookFilter(ENABLED_SET); }); + describe('shouldSkip', () => { it('should allow a hook named in enabled set', () => { expect(filter.shouldSkip('enabled-hook')).to.be.false; }); + it('should not allow a hook not named in enabled set', () => { expect(filter.shouldSkip('not-enabled-hook')).to.be.true; }); diff --git a/tests/IntentUtilsTest.ts b/tests/IntentUtilsTest.ts index 1fcae634f..b90ef4f99 100644 --- a/tests/IntentUtilsTest.ts +++ b/tests/IntentUtilsTest.ts @@ -40,7 +40,7 @@ describe("IntentUtils", () => { expect(hasInvited).to.be.true; }); - it("invites the user to the room and joins", () => { + it("should fail if the bot was not invited to the room", () => { const targetIntent = IntentMock.create(SENDER_USER_ID); const matrixClient = MatrixClientMock.create(); diff --git a/tests/MessageQueueTest.ts b/tests/MessageQueueTest.ts index a3fa0d63f..62920685a 100644 --- a/tests/MessageQueueTest.ts +++ b/tests/MessageQueueTest.ts @@ -25,6 +25,7 @@ describe("MessageQueueTest", () => { data: 51, }); }); + it("should be able to push an event, and respond to it", async () => { mq.subscribe("fakeevent2"); mq.subscribe("response.fakeevent2"); diff --git a/tests/config/permissions.ts b/tests/config/permissions.ts index a61254d90..77e94ee58 100644 --- a/tests/config/permissions.ts +++ b/tests/config/permissions.ts @@ -21,43 +21,53 @@ describe("Config/BridgePermissions", () => { const bridgePermissions = new BridgePermissions([]); expect(bridgePermissions.checkAction("@foo:bar", "empty-service", "commands")).to.be.false; }); + it("will return false for an insufficent level", () => { const bridgePermissions = genBridgePermissions('@foo:bar', 'my-service', 'login'); expect(bridgePermissions.checkAction("@foo:bar", "my-service", "notifications")).to.be.false; }); + it("will return false if the there are no matching services", () => { const bridgePermissions = genBridgePermissions('@foo:bar', 'my-service', 'login'); expect(bridgePermissions.checkAction("@foo:bar", "other-service", "login")).to.be.false; }); + it("will return false if the target does not match", () => { const bridgePermissions = genBridgePermissions('@foo:bar', 'my-service', 'login'); expect(bridgePermissions.checkAction("@foo:baz", "my-service", "login")).to.be.false; }); + it("will return true if there is a matching level and service", () => { const bridgePermissions = genBridgePermissions('@foo:bar', 'my-service', 'login'); expect(bridgePermissions.checkAction("@foo:bar", "my-service", "login")).to.be.true; }); + it("will return true for a matching actor domain", () => { const bridgePermissions = genBridgePermissions('bar', 'my-service', 'login'); expect(bridgePermissions.checkAction("@foo:bar", "my-service", "login")).to.be.true; }); + it("will return true for a wildcard actor", () => { const bridgePermissions = genBridgePermissions('*', 'my-service', 'login'); expect(bridgePermissions.checkAction("@foo:bar", "my-service", "login")).to.be.true; }); + it("will return true for a wildcard service", () => { const bridgePermissions = genBridgePermissions('@foo:bar', '*', 'login'); expect(bridgePermissions.checkAction("@foo:bar", "my-service", "login")).to.be.true; }); + it("will return false if a user is not present in a room", () => { const bridgePermissions = genBridgePermissions('!foo:bar', 'my-service', 'login'); expect(bridgePermissions.checkAction("@foo:bar", "my-service", "login")).to.be.false; }); + it("will return true if a user is present in a room", () => { const bridgePermissions = genBridgePermissions('!foo:bar', 'my-service', 'login'); bridgePermissions.addMemberToCache('!foo:bar', '@foo:bar'); expect(bridgePermissions.checkAction("@foo:bar", "my-service", "login")).to.be.true; }); + it("will fall through and return true for multiple permission sets", () => { const bridgePermissions = new BridgePermissions([ { @@ -98,6 +108,7 @@ describe("Config/BridgePermissions", () => { const bridgePermissions = new BridgePermissions([]); expect(bridgePermissions.checkActionAny("@foo:bar", "commands")).to.be.false; }); + it(`will return false for a service with an insufficent level`, () => { const bridgePermissions = genBridgePermissions("@foo:bar", "fake-service", "commands"); expect( @@ -107,17 +118,35 @@ describe("Config/BridgePermissions", () => { ) ).to.be.false; }); - const checkActorValues = ["@foo:bar", "bar", "*"]; - checkActorValues.forEach(actor => { - it(`will return true for a service defintion of '${actor}' that has a sufficent level`, () => { - const bridgePermissions = genBridgePermissions("@foo:bar", "fake-service", "commands"); - expect( - bridgePermissions.checkActionAny( - "@foo:bar", - "commands" - ) - ).to.be.true; - }); + + it(`will check that a userId actor has a sufficent level`, () => { + const bridgePermissions = genBridgePermissions("@foo:bar", "fake-service", "commands"); + expect( + bridgePermissions.checkActionAny( + "@foo:bar", + "commands" + ) + ).to.be.true; + }); + + it(`will check that a homeserver actor has a sufficent level`, () => { + const bridgePermissions = genBridgePermissions("bar", "fake-service", "commands"); + expect( + bridgePermissions.checkActionAny( + "@foo:bar", + "commands" + ) + ).to.be.true; + }); + + it(`will check that a wildcard actor has a sufficent level`, () => { + const bridgePermissions = genBridgePermissions("*", "fake-service", "commands"); + expect( + bridgePermissions.checkActionAny( + "@foo:bar", + "commands" + ) + ).to.be.true; }); }) }) \ No newline at end of file diff --git a/tests/connections/FeedTest.spec.ts b/tests/connections/FeedTest.spec.ts index 6bb268411..17db20bde 100644 --- a/tests/connections/FeedTest.spec.ts +++ b/tests/connections/FeedTest.spec.ts @@ -47,6 +47,7 @@ describe("FeedConnection", () => { expect(matrixEvt.content.external_url).to.equal(FEED_ENTRY_DEFAULTS.link); expect(matrixEvt.content.body).to.equal("New post in Test feed: [Foo](foo/bar)"); }); + it("will handle simple feed message without a title and link ", async () => { const [connection, intent] = createFeed(); await connection.handleFeedEntry({ @@ -60,6 +61,7 @@ describe("FeedConnection", () => { expect(matrixEvt.content.external_url).to.be.undefined; expect(matrixEvt.content.body).to.equal("New post in Test feed"); }); + it("will handle simple feed message with a missing title ", async () => { const [connection, intent] = createFeed(); await connection.handleFeedEntry({ @@ -71,6 +73,7 @@ describe("FeedConnection", () => { expect(matrixEvt.roomId).to.equal(ROOM_ID); expect(matrixEvt.content.body).to.equal("New post in Test feed: [foo/bar](foo/bar)"); }); + it("will handle simple feed message with a missing link ", async () => { const [connection, intent] = createFeed(); await connection.handleFeedEntry({ @@ -82,6 +85,7 @@ describe("FeedConnection", () => { expect(matrixEvt.roomId).to.equal(ROOM_ID); expect(matrixEvt.content.body).to.equal("New post in Test feed: Foo"); }); + it("will handle simple feed message with all the template options possible ", async () => { const [connection, intent] = createFeed({ template: `$FEEDNAME $FEEDURL $FEEDTITLE $TITLE $LINK $AUTHOR $DATE $SUMMARY` @@ -94,6 +98,7 @@ describe("FeedConnection", () => { expect(matrixEvt.roomId).to.equal(ROOM_ID); expect(matrixEvt.content.body).to.equal("Test feed https://example.com/feed.xml Test feed Foo [Foo](foo/bar) Me! today! fibble fobble"); }); + it("will handle html in the feed summary ", async () => { const [connection, intent] = createFeed({ template: `$FEEDNAME $SUMMARY` @@ -107,6 +112,7 @@ describe("FeedConnection", () => { expect(matrixEvt.roomId).to.equal(ROOM_ID); expect(matrixEvt.content.body).to.equal('Test feed

Some HTML with which should be ignored and an

'); }); + it("will handle partial html in the feed summary ", async () => { const [connection, intent] = createFeed({ template: `$FEEDNAME $SUMMARY` diff --git a/tests/connections/GenericHookTest.ts b/tests/connections/GenericHookTest.ts index 007c880d6..b0f7e8c5a 100644 --- a/tests/connections/GenericHookTest.ts +++ b/tests/connections/GenericHookTest.ts @@ -65,10 +65,12 @@ describe("GenericHookConnection", () => { before(async () => { await GenericHookConnection.initialiseQuickJS(); }) + it("will handle simple hook events", async () => { const [connection, mq] = createGenericHook(); await testSimpleWebhook(connection, mq, "data"); }); + it("will handle a hook event containing text", async () => { const webhookData = {text: "simple-message"}; const [connection, mq] = createGenericHook(); @@ -87,6 +89,7 @@ describe("GenericHookConnection", () => { type: 'm.room.message', }); }); + it("will handle a hook event containing markdown", async () => { const webhookData = {text: "**bold-message** _italic-message_"}; const [connection, mq] = createGenericHook(); @@ -105,6 +108,7 @@ describe("GenericHookConnection", () => { type: 'm.room.message', }); }); + it("will handle a hook event containing markdown with newlines", async () => { const webhookData = {text: "# Oh wow\n\n`some-code`"}; const [connection, mq] = createGenericHook(); @@ -123,6 +127,7 @@ describe("GenericHookConnection", () => { type: 'm.room.message', }); }); + it("will handle a hook event containing html", async () => { const webhookData = {text: "simple-message", html: "simple-message"}; const [connection, mq] = createGenericHook(); @@ -141,6 +146,7 @@ describe("GenericHookConnection", () => { type: 'm.room.message', }); }); + it("will handle a hook event containing a username", async () => { const webhookData = {username: "Bobs-integration", type: 42}; const [connection, mq] = createGenericHook(); @@ -159,6 +165,7 @@ describe("GenericHookConnection", () => { type: 'm.room.message', }); }); + it("will handle a hook event with a v1 transformation function", async () => { const webhookData = {question: 'What is the meaning of life?', answer: 42}; const [connection, mq] = createGenericHook({name: 'test', transformationFunction: V1TFFunction}, { @@ -182,6 +189,7 @@ describe("GenericHookConnection", () => { type: 'm.room.message', }); }); + it("will handle a hook event with a v2 transformation function", async () => { const webhookData = {question: 'What is the meaning of life?', answer: 42}; const [connection, mq] = createGenericHook({name: 'test', transformationFunction: V2TFFunction}, { @@ -205,6 +213,7 @@ describe("GenericHookConnection", () => { type: 'm.room.message', }); }); + it("will handle a hook event with a top-level return", async () => { const webhookData = {question: 'What is the meaning of life?', answer: 42}; const [connection, mq] = createGenericHook({name: 'test', transformationFunction: V2TFFunctionWithReturn}, { @@ -228,6 +237,7 @@ describe("GenericHookConnection", () => { type: 'm.room.message', }); }); + it("will fail to handle a webhook with an invalid script", async () => { const webhookData = {question: 'What is the meaning of life?', answer: 42}; const [connection, mq] = createGenericHook({name: 'test', transformationFunction: "bibble bobble"}, { @@ -251,6 +261,7 @@ describe("GenericHookConnection", () => { type: 'm.room.message', }); }); + it("will handle a message containing floats", async () => { const [connection, mq] = createGenericHook(); let messagePromise = handleMessage(mq); diff --git a/tests/connections/GithubRepoTest.ts b/tests/connections/GithubRepoTest.ts index e686f2a5c..31ad689fe 100644 --- a/tests/connections/GithubRepoTest.ts +++ b/tests/connections/GithubRepoTest.ts @@ -86,6 +86,7 @@ describe("GitHubRepoConnection", () => { } } as GitHubRepoConnectionState as unknown as Record); }); + it("will convert ignoredHooks for existing state", () => { const state = GitHubRepoConnection.validateState({ org: "foo", @@ -96,6 +97,7 @@ describe("GitHubRepoConnection", () => { } as GitHubRepoConnectionState as unknown as Record, true); expect(state.enableHooks).to.not.contain('issue'); }); + it("will disallow invalid state", () => { try { GitHubRepoConnection.validateState({ @@ -108,6 +110,7 @@ describe("GitHubRepoConnection", () => { } } }); + it("will disallow enabledHooks to contains invalid enums if this is new state", () => { try { GitHubRepoConnection.validateState({ @@ -121,6 +124,7 @@ describe("GitHubRepoConnection", () => { } } }); + it("will allow enabledHooks to contains invalid enums if this is old state", () => { GitHubRepoConnection.validateState({ org: "foo", @@ -129,6 +133,7 @@ describe("GitHubRepoConnection", () => { }, true); }); }); + describe("onIssueCreated", () => { it("will handle a simple issue", async () => { const { connection, intent } = createConnection(); @@ -138,6 +143,7 @@ describe("GitHubRepoConnection", () => { intent.expectEventBodyContains(GITHUB_ISSUE_CREATED_PAYLOAD.issue.html_url, 0); intent.expectEventBodyContains(GITHUB_ISSUE_CREATED_PAYLOAD.issue.title, 0); }); + it("will handle assignees on issue creation", async () => { const { connection, intent } = createConnection(); await connection.onIssueCreated({ @@ -153,6 +159,7 @@ describe("GitHubRepoConnection", () => { intent.expectEventBodyContains(GITHUB_ISSUE_CREATED_PAYLOAD.issue.html_url, 0); intent.expectEventBodyContains(GITHUB_ISSUE_CREATED_PAYLOAD.issue.title, 0); }); + it("will filter out issues not matching includingLabels.", async () => { const { connection, intent } = createConnection({ includingLabels: ["include-me"] @@ -170,6 +177,7 @@ describe("GitHubRepoConnection", () => { await connection.onIssueCreated(GITHUB_ISSUE_CREATED_PAYLOAD as never); intent.expectNoEvent(); }); + it("will filter out issues matching excludingLabels.", async () => { const { connection, intent } = createConnection({ excludingLabels: ["exclude-me"] @@ -185,6 +193,7 @@ describe("GitHubRepoConnection", () => { } as never); intent.expectNoEvent(); }); + it("will include issues matching includingLabels.", async () => { const { connection, intent } = createConnection({ includingIssues: ["include-me"] diff --git a/tests/connections/GitlabRepoTest.ts b/tests/connections/GitlabRepoTest.ts index ee87d7658..f59c7e3ec 100644 --- a/tests/connections/GitlabRepoTest.ts +++ b/tests/connections/GitlabRepoTest.ts @@ -116,6 +116,7 @@ describe("GitLabRepoConnection", () => { excludingLabels: ["but-not-me"], } as GitLabRepoConnectionState as unknown as Record); }); + it("will convert ignoredHooks for existing state", () => { const state = GitLabRepoConnection.validateState({ instance: "foo", @@ -127,6 +128,7 @@ describe("GitLabRepoConnection", () => { } as GitLabRepoConnectionState as unknown as Record, true); expect(state.enableHooks).to.not.contain('merge_request'); }); + it("will disallow invalid state", () => { try { GitLabRepoConnection.validateState({ @@ -139,6 +141,7 @@ describe("GitLabRepoConnection", () => { } } }); + it("will disallow enabledHooks to contains invalid enums if this is new state", () => { try { GitLabRepoConnection.validateState({ @@ -152,6 +155,7 @@ describe("GitLabRepoConnection", () => { } } }); + it("will allow enabledHooks to contains invalid enums if this is old state", () => { GitLabRepoConnection.validateState({ instance: "bar", @@ -160,6 +164,7 @@ describe("GitLabRepoConnection", () => { }, true); }); }); + describe("onCommentCreated", () => { it("will handle an MR comment", async () => { const { connection, intent } = createConnection(); @@ -170,6 +175,7 @@ describe("GitLabRepoConnection", () => { 'event body indicates MR comment' ); }); + it("will debounce MR comments", async () => { const { connection, intent } = createConnection(); await connection.onCommentCreated(GITLAB_MR_COMMENT as never); @@ -189,6 +195,7 @@ describe("GitLabRepoConnection", () => { 0, ); }); + it("will add new comments in a Matrix thread", async () => { const { connection, intent } = createConnection(); await connection.onCommentCreated(GITLAB_MR_COMMENT as never); @@ -202,6 +209,7 @@ describe("GitLabRepoConnection", () => { 1, ); }); + it("will correctly map new comments to aggregated discussions", async () => { const { connection, intent } = createConnection(); await connection.onCommentCreated({ @@ -252,6 +260,7 @@ describe("GitLabRepoConnection", () => { ); }); }); + describe("onIssueCreated", () => { it("will handle a simple issue", async () => { const { connection, intent } = createConnection(); @@ -261,6 +270,7 @@ describe("GitLabRepoConnection", () => { intent.expectEventBodyContains(GITLAB_ISSUE_CREATED_PAYLOAD.object_attributes.url, 0); intent.expectEventBodyContains(GITLAB_ISSUE_CREATED_PAYLOAD.object_attributes.title, 0); }); + it("will filter out issues not matching includingLabels.", async () => { const { connection, intent } = createConnection({ includingLabels: ["include-me"] @@ -275,6 +285,7 @@ describe("GitLabRepoConnection", () => { await connection.onMergeRequestOpened(GITLAB_ISSUE_CREATED_PAYLOAD as never); intent.expectNoEvent(); }); + it("will filter out issues matching excludingLabels.", async () => { const { connection, intent } = createConnection({ excludingLabels: ["exclude-me"] @@ -287,6 +298,7 @@ describe("GitLabRepoConnection", () => { } as never); intent.expectNoEvent(); }); + it("will include issues matching includingLabels.", async () => { const { connection, intent } = createConnection({ includingIssues: ["include-me"] diff --git a/tests/github/AdminCommands.ts b/tests/github/AdminCommands.ts index 5bc0aa5e7..f614b82e3 100644 --- a/tests/github/AdminCommands.ts +++ b/tests/github/AdminCommands.ts @@ -13,6 +13,7 @@ describe("GitHub", () => { }) ).equals('https://github.com/login/oauth/authorize?state=my_state&client_id=123&redirect_uri=456'); }); + it("can generate an authorize URL for enterprise URLs", () => { expect( GithubInstance.generateOAuthUrl(new URL("https://mygithuburl.com/foo/bar"), "authorize", { @@ -22,6 +23,7 @@ describe("GitHub", () => { }) ).equals('https://mygithuburl.com/foo/bar/login/oauth/authorize?state=my_state&client_id=123&redirect_uri=456'); }); + it("can generate an access_token URL for the cloud URL", () => { expect( GithubInstance.generateOAuthUrl(GITHUB_CLOUD_URL, "access_token", { @@ -33,6 +35,7 @@ describe("GitHub", () => { }) ).equals('https://github.com/login/oauth/access_token?client_id=123&client_secret=the-secret&code=the-code&redirect_uri=456&state=my_state'); }); + it("can generate an access_token URL for enterprise URLs", () => { expect( GithubInstance.generateOAuthUrl(new URL("https://mygithuburl.com/foo/bar"), "access_token", { diff --git a/tests/grants/GrantChecker.spec.ts b/tests/grants/GrantChecker.spec.ts index caa6e59df..5e81e625a 100644 --- a/tests/grants/GrantChecker.spec.ts +++ b/tests/grants/GrantChecker.spec.ts @@ -39,6 +39,7 @@ describe("GrantChecker", () => { let check: GrantChecker; // eslint-disable-next-line @typescript-eslint/no-explicit-any let intent: any; + beforeEach(() => { intent = IntentMock.create('@foo:bar'); check = new TestGrantChecker(intent, GRANT_SERVICE); @@ -95,9 +96,11 @@ describe("GrantChecker", () => { ); }); }); + describe('config fallback', () => { let check: GrantChecker; let as: AppserviceMock; + beforeEach(() => { const mockAs = AppserviceMock.create(); as = mockAs; diff --git a/tests/jira/Utils.ts b/tests/jira/Utils.ts index 3f9050ed4..eef24ec6a 100644 --- a/tests/jira/Utils.ts +++ b/tests/jira/Utils.ts @@ -9,13 +9,15 @@ describe("Jira", () => { key: "TEST-111", })).to.equal("https://my-test-jira/browse/TEST-111"); }); - it("processes a jira issue into a URL with a port", () => { + + it("processes a jira issue with a port into a URL", () => { expect(generateJiraWebLinkFromIssue({ self: "https://my-test-jira:9995/", key: "TEST-111", })).to.equal("https://my-test-jira:9995/browse/TEST-111"); }); - it("processes a jira issue into a URL with a port", () => { + + it("processes a jira issue with a version into a URL", () => { expect(generateJiraWebLinkFromVersion({ self: "https://my-test-jira:9995/", description: "foo", diff --git a/yarn.lock b/yarn.lock index 8e4fb1fac..b9dfb5a8b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3736,12 +3736,13 @@ eslint-plugin-jest@^25.2.4: dependencies: "@typescript-eslint/experimental-utils" "^5.0.0" -eslint-plugin-mocha@^10.1.0: - version "10.2.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-mocha/-/eslint-plugin-mocha-10.2.0.tgz#15b05ce5be4b332bb0d76826ec1c5ebf67102ad6" - integrity sha512-ZhdxzSZnd1P9LqDPF0DBcFLpRIGdh1zkF2JHnQklKQOvrQtT73kdP5K9V2mzvbLR+cCAO9OI48NXK/Ax9/ciCQ== +eslint-plugin-mocha@^10.4.2: + version "10.4.2" + resolved "https://registry.yarnpkg.com/eslint-plugin-mocha/-/eslint-plugin-mocha-10.4.2.tgz#dfaed06d362506c5e4d561c534314e25b3b0f1f7" + integrity sha512-cur4dVYnSEWTBwdqIBQFxa/9siAhesu0TX+lbJ4ClE9j0eNMNe6BSx3vkFFNz6tGoveyMyELFXa30f3fvuAVDg== dependencies: eslint-utils "^3.0.0" + globals "^13.24.0" rambda "^7.4.0" eslint-plugin-react-hooks@^4.3.0: @@ -4367,7 +4368,7 @@ globals@^11.1.0: resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== -globals@^13.19.0: +globals@^13.19.0, globals@^13.24.0: version "13.24.0" resolved "https://registry.yarnpkg.com/globals/-/globals-13.24.0.tgz#8432a19d78ce0c1e833949c36adb345400bb1171" integrity sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==