@evanp/activitypub-bot 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/.github/dependabot.yml +11 -0
  2. package/.github/workflows/main-docker.yml +45 -0
  3. package/.github/workflows/tag-docker.yml +54 -0
  4. package/Dockerfile +23 -0
  5. package/LICENSE +661 -0
  6. package/README.md +82 -0
  7. package/bots/index.js +7 -0
  8. package/docs/activitypub.bot.drawio +110 -0
  9. package/index.js +23 -0
  10. package/lib/activitydistributor.js +263 -0
  11. package/lib/activityhandler.js +999 -0
  12. package/lib/activitypubclient.js +126 -0
  13. package/lib/activitystreams.js +41 -0
  14. package/lib/actorstorage.js +300 -0
  15. package/lib/app.js +173 -0
  16. package/lib/authorizer.js +133 -0
  17. package/lib/bot.js +44 -0
  18. package/lib/botcontext.js +520 -0
  19. package/lib/botdatastorage.js +87 -0
  20. package/lib/bots/donothing.js +11 -0
  21. package/lib/bots/ok.js +41 -0
  22. package/lib/digester.js +23 -0
  23. package/lib/httpsignature.js +195 -0
  24. package/lib/httpsignatureauthenticator.js +81 -0
  25. package/lib/keystorage.js +113 -0
  26. package/lib/microsyntax.js +140 -0
  27. package/lib/objectcache.js +48 -0
  28. package/lib/objectstorage.js +319 -0
  29. package/lib/remotekeystorage.js +116 -0
  30. package/lib/routes/collection.js +92 -0
  31. package/lib/routes/health.js +24 -0
  32. package/lib/routes/inbox.js +83 -0
  33. package/lib/routes/object.js +69 -0
  34. package/lib/routes/server.js +47 -0
  35. package/lib/routes/user.js +63 -0
  36. package/lib/routes/webfinger.js +36 -0
  37. package/lib/urlformatter.js +97 -0
  38. package/package.json +51 -0
  39. package/tests/activitydistributor.test.js +606 -0
  40. package/tests/activityhandler.test.js +2185 -0
  41. package/tests/activitypubclient.test.js +225 -0
  42. package/tests/actorstorage.test.js +261 -0
  43. package/tests/app.test.js +17 -0
  44. package/tests/authorizer.test.js +306 -0
  45. package/tests/bot.donothing.test.js +30 -0
  46. package/tests/bot.ok.test.js +101 -0
  47. package/tests/botcontext.test.js +674 -0
  48. package/tests/botdatastorage.test.js +87 -0
  49. package/tests/digester.test.js +56 -0
  50. package/tests/fixtures/bots.js +15 -0
  51. package/tests/httpsignature.test.js +200 -0
  52. package/tests/httpsignatureauthenticator.test.js +463 -0
  53. package/tests/keystorage.test.js +89 -0
  54. package/tests/microsyntax.test.js +122 -0
  55. package/tests/objectcache.test.js +133 -0
  56. package/tests/objectstorage.test.js +148 -0
  57. package/tests/remotekeystorage.test.js +76 -0
  58. package/tests/routes.actor.test.js +207 -0
  59. package/tests/routes.collection.test.js +434 -0
  60. package/tests/routes.health.test.js +41 -0
  61. package/tests/routes.inbox.test.js +135 -0
  62. package/tests/routes.object.test.js +519 -0
  63. package/tests/routes.server.test.js +69 -0
  64. package/tests/routes.webfinger.test.js +41 -0
  65. package/tests/urlformatter.test.js +164 -0
  66. package/tests/utils/digest.js +7 -0
  67. package/tests/utils/nock.js +276 -0
@@ -0,0 +1,225 @@
1
+ import { describe, before, after, it, beforeEach } from 'node:test'
2
+ import { KeyStorage } from '../lib/keystorage.js'
3
+ import { UrlFormatter } from '../lib/urlformatter.js'
4
+ import { ActivityPubClient } from '../lib/activitypubclient.js'
5
+ import assert from 'node:assert'
6
+ import { Sequelize } from 'sequelize'
7
+ import nock from 'nock'
8
+ import as2 from '../lib/activitystreams.js'
9
+ import Logger from 'pino'
10
+ import { HTTPSignature } from '../lib/httpsignature.js'
11
+ import { Digester } from '../lib/digester.js'
12
+
13
+ const makeActor = (username) =>
14
+ as2.import({
15
+ id: `https://social.example/user/${username}`,
16
+ type: 'Person',
17
+ preferredUsername: username,
18
+ inbox: `https://social.example/user/${username}/inbox`,
19
+ outbox: `https://social.example/user/${username}/outbox`,
20
+ followers: `https://social.example/user/${username}/followers`,
21
+ following: `https://social.example/user/${username}/following`,
22
+ liked: `https://social.example/user/${username}/liked`,
23
+ publicKey: {
24
+ id: `https://social.example/user/${username}/publickey`,
25
+ owner: `https://social.example/user/${username}`,
26
+ type: 'CryptographicKey',
27
+ publicKeyPem: '-----BEGIN PUBLIC KEY-----\nFAKEFAKEFAKE\n-----END PUBLIC KEY-----'
28
+ }
29
+ })
30
+
31
+ const makeKey = (username) =>
32
+ as2.import({
33
+ id: `https://social.example/user/${username}/publickey`,
34
+ owner: `https://social.example/user/${username}`,
35
+ type: 'CryptographicKey',
36
+ publicKeyPem: '-----BEGIN PUBLIC KEY-----\nFAKEFAKEFAKE\n-----END PUBLIC KEY-----'
37
+ })
38
+
39
+ const makeNote = (username, num) =>
40
+ as2.import({
41
+ id: `https://social.example/user/${username}/note/${num}`,
42
+ type: 'Object',
43
+ attributedTo: `https://social.example/user/${username}`,
44
+ to: 'https://www.w3.org/ns/activitystreams#Public',
45
+ content: `This is note ${num} by ${username}.`
46
+ })
47
+
48
+ describe('ActivityPubClient', async () => {
49
+ let connection = null
50
+ let keyStorage = null
51
+ let formatter = null
52
+ let client = null
53
+ let postInbox = null
54
+ let signature = null
55
+ let digest = null
56
+ let date = null
57
+ let signer = null
58
+ let digester = null
59
+ let logger = null
60
+ before(async () => {
61
+ logger = new Logger({
62
+ level: 'silent'
63
+ })
64
+ digester = new Digester(logger)
65
+ signer = new HTTPSignature(logger)
66
+ connection = new Sequelize('sqlite::memory:', { logging: false })
67
+ await connection.authenticate()
68
+ keyStorage = new KeyStorage(connection, logger)
69
+ await keyStorage.initialize()
70
+ formatter = new UrlFormatter('https://activitypubbot.example')
71
+ const remote = 'https://social.example'
72
+ nock(remote)
73
+ .get(/\/user\/(\w+)$/)
74
+ .reply(async function (uri, requestBody) {
75
+ const headers = this.req.headers
76
+ signature[remote + uri] = headers.signature
77
+ digest[remote + uri] = headers.digest
78
+ date[remote + uri] = headers.date
79
+ const username = uri.match(/\/user\/(\w+)$/)[1]
80
+ const actor = await makeActor(username)
81
+ const actorText = await actor.write()
82
+ return [200, actorText, { 'Content-Type': 'application/activity+json' }]
83
+ })
84
+ .persist()
85
+ .post(/\/user\/(\w+)\/inbox$/)
86
+ .reply(async function (uri, requestBody) {
87
+ const headers = this.req.headers
88
+ signature[remote + uri] = headers.signature
89
+ digest[remote + uri] = headers.digest
90
+ date[remote + uri] = headers.date
91
+ const username = uri.match(/\/user\/(\w+)\/inbox$/)[1]
92
+ if (username in postInbox) {
93
+ postInbox[username] += 1
94
+ } else {
95
+ postInbox[username] = 1
96
+ }
97
+ return [202, 'accepted']
98
+ })
99
+ .persist()
100
+ .get(/\/user\/(\w+)\/note\/(\d+)$/)
101
+ .reply(async function (uri, requestBody) {
102
+ const headers = this.req.headers
103
+ signature[remote + uri] = headers.signature
104
+ digest[remote + uri] = headers.digest
105
+ date[remote + uri] = headers.date
106
+ const match = uri.match(/\/user\/(\w+)\/note\/(\d+)$/)
107
+ const username = match[1]
108
+ const num = match[2]
109
+ const obj = await makeNote(username, num)
110
+ const objText = await obj.write()
111
+ return [200, objText, { 'Content-Type': 'application/activity+json' }]
112
+ })
113
+ .get(/\/user\/(\w+)\/inbox$/)
114
+ .reply(async function (uri, requestBody) {
115
+ return [403, 'Forbidden', { 'Content-Type': 'text/plain' }]
116
+ })
117
+ .get(/\/user\/(\w+)\/publickey$/)
118
+ .reply(async function (uri, requestBody) {
119
+ const headers = this.req.headers
120
+ signature[remote + uri] = headers.signature
121
+ digest[remote + uri] = headers.digest
122
+ date[remote + uri] = headers.date
123
+ const username = uri.match(/\/user\/(\w+)\/publickey$/)[1]
124
+ const key = await makeKey(username)
125
+ const keyText = await key.write()
126
+ return [200, keyText, { 'Content-Type': 'application/activity+json' }]
127
+ })
128
+ .persist()
129
+ })
130
+ after(async () => {
131
+ await connection.close()
132
+ keyStorage = null
133
+ connection = null
134
+ formatter = null
135
+ client = null
136
+ postInbox = null
137
+ signature = null
138
+ logger = null
139
+ digester = null
140
+ signer = null
141
+ })
142
+ beforeEach(async () => {
143
+ signature = {}
144
+ digest = {}
145
+ postInbox = {}
146
+ date = {}
147
+ })
148
+ it('can initialize', () => {
149
+ client = new ActivityPubClient(keyStorage, formatter, signer, digester, logger)
150
+ assert.ok(client)
151
+ })
152
+ it('can get a remote object with a username', async () => {
153
+ const id = 'https://social.example/user/evan/note/1'
154
+ const obj = await client.get(id, 'foobot')
155
+ assert.ok(obj)
156
+ assert.equal(typeof obj, 'object')
157
+ assert.equal(obj.id, id)
158
+ assert.ok(signature[id])
159
+ assert.match(signature[id], /^keyId="https:\/\/activitypubbot\.example\/user\/foobot\/publickey",headers="\(request-target\) host date user-agent accept",signature=".*",algorithm="rsa-sha256"$/)
160
+ assert.equal(typeof digest[id], 'undefined')
161
+ assert.equal(typeof date[id], 'string')
162
+ assert.match(date[id], /^\w{3}, \d{2} \w{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/)
163
+ assert.doesNotThrow(() => {
164
+ Date.parse(date[id])
165
+ })
166
+ })
167
+ it('can get a remote object without a username', async () => {
168
+ const id = 'https://social.example/user/evan/note/1'
169
+ const obj = await client.get(id)
170
+ assert.ok(obj)
171
+ assert.equal(typeof obj, 'object')
172
+ assert.equal(obj.id, id)
173
+ assert.ok(signature[id])
174
+ assert.match(signature[id], /^keyId="https:\/\/activitypubbot\.example\/publickey",headers="\(request-target\) host date user-agent accept",signature=".*",algorithm="rsa-sha256"$/)
175
+ assert.equal(typeof digest[id], 'undefined')
176
+ assert.equal(typeof date[id], 'string')
177
+ assert.match(date[id], /^\w{3}, \d{2} \w{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/)
178
+ assert.doesNotThrow(() => {
179
+ Date.parse(date[id])
180
+ })
181
+ })
182
+ it('can get a remote key without a signature', async () => {
183
+ const id = 'https://social.example/user/evan/publickey'
184
+ const obj = await client.getKey(id)
185
+ assert.ok(obj)
186
+ assert.equal(typeof obj, 'object')
187
+ assert.equal(obj.id, id)
188
+ assert.equal(signature[id], undefined)
189
+ assert.equal(typeof digest[id], 'undefined')
190
+ assert.equal(typeof date[id], 'string')
191
+ assert.match(date[id], /^\w{3}, \d{2} \w{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/)
192
+ assert.doesNotThrow(() => {
193
+ Date.parse(date[id])
194
+ })
195
+ })
196
+ it('can deliver an activity', async () => {
197
+ const obj = as2.follow()
198
+ .actor('https://activitypubbot.example/user/foobot')
199
+ .object('https://social.example/user/evan')
200
+ .to('https://social.example/user/evan')
201
+ .publishedNow()
202
+ .get()
203
+ const inbox = 'https://social.example/user/evan/inbox'
204
+ await client.post(inbox, obj, 'foobot')
205
+ assert.ok(signature[inbox])
206
+ assert.ok(digest[inbox])
207
+ assert.match(signature[inbox], /^keyId="https:\/\/activitypubbot\.example\/user\/foobot\/publickey",headers="\(request-target\) host date user-agent content-type digest",signature=".*",algorithm="rsa-sha256"$/)
208
+ assert.match(digest[inbox], /^sha-256=[0-9a-zA-Z=+/]*$/)
209
+ assert.equal(typeof date[inbox], 'string')
210
+ assert.match(date[inbox], /^\w{3}, \d{2} \w{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/)
211
+ assert.doesNotThrow(() => {
212
+ Date.parse(date[inbox])
213
+ })
214
+ })
215
+ it('throws an error on a non-2xx response', async () => {
216
+ const inbox = 'https://social.example/user/evan/inbox'
217
+ try {
218
+ await client.get(inbox, 'foobot')
219
+ assert.fail('should have thrown')
220
+ } catch (error) {
221
+ assert.ok(error)
222
+ assert.equal(error.status, 403)
223
+ }
224
+ })
225
+ })
@@ -0,0 +1,261 @@
1
+ import { describe, it, before, after } from 'node:test'
2
+ import assert from 'node:assert'
3
+ import { ActorStorage } from '../lib/actorstorage.js'
4
+ import { Sequelize } from 'sequelize'
5
+ import { UrlFormatter } from '../lib/urlformatter.js'
6
+ import as2 from '../lib/activitystreams.js'
7
+
8
+ const AS2_NS = 'https://www.w3.org/ns/activitystreams#'
9
+
10
+ describe('ActorStorage', () => {
11
+ let connection = null
12
+ let storage = null
13
+ let formatter = null
14
+ let other = null
15
+ before(async () => {
16
+ connection = new Sequelize('sqlite::memory:', { logging: false })
17
+ await connection.authenticate()
18
+ formatter = new UrlFormatter('https://activitypubbot.example')
19
+ other = await as2.import({
20
+ id: 'https://social.example/user/test2',
21
+ type: 'Person'
22
+ })
23
+ })
24
+ after(async () => {
25
+ await connection.close()
26
+ connection = null
27
+ formatter = null
28
+ })
29
+ it('can create an instance', () => {
30
+ storage = new ActorStorage(connection, formatter)
31
+ assert.ok(storage instanceof ActorStorage)
32
+ })
33
+ it('can initialize the storage', async () => {
34
+ await storage.initialize()
35
+ })
36
+ it('can get an actor', async () => {
37
+ const actor = await storage.getActor('test')
38
+ assert.ok(actor)
39
+ assert.ok(actor.id)
40
+ assert.ok(actor.inbox)
41
+ assert.ok(actor.outbox)
42
+ assert.ok(actor.followers)
43
+ assert.ok(actor.following)
44
+ assert.ok(actor.liked)
45
+ assert.strictEqual(actor.get('preferredUsername').first, 'test')
46
+ })
47
+
48
+ it('can get an actor by id', async () => {
49
+ const actor = await storage.getActorById('https://activitypubbot.example/user/test')
50
+ assert.ok(actor)
51
+ assert.ok(actor.id)
52
+ assert.ok(actor.inbox)
53
+ assert.ok(actor.outbox)
54
+ assert.ok(actor.followers)
55
+ assert.ok(actor.following)
56
+ assert.ok(actor.liked)
57
+ assert.strictEqual(actor.get('preferredUsername').first, 'test')
58
+ })
59
+ it('can get an empty collection', async () => {
60
+ const collection = await storage.getCollection('test', 'followers')
61
+ assert.ok(collection)
62
+ assert.strictEqual(collection.id, 'https://activitypubbot.example/user/test/followers')
63
+ assert.strictEqual(collection.type, 'https://www.w3.org/ns/activitystreams#OrderedCollection')
64
+ assert.strictEqual(collection.totalItems, 0)
65
+ assert.ok(collection.first)
66
+ assert.ok(collection.last)
67
+ })
68
+ it('can get an empty collection page', async () => {
69
+ const page = await storage.getCollectionPage('test', 'followers', 1)
70
+ assert.ok(page)
71
+ assert.strictEqual(
72
+ page.id,
73
+ 'https://activitypubbot.example/user/test/followers/1'
74
+ )
75
+ assert.strictEqual(page.type, 'https://www.w3.org/ns/activitystreams#OrderedCollectionPage')
76
+ assert.strictEqual(
77
+ page.partOf.id,
78
+ 'https://activitypubbot.example/user/test/followers'
79
+ )
80
+ assert.ok(!page.next)
81
+ assert.ok(!page.prev)
82
+ })
83
+ it('can add to a collection', async () => {
84
+ const collection = await storage.getCollection('test3', 'followers')
85
+ assert.strictEqual(collection.totalItems, 0)
86
+ await storage.addToCollection(
87
+ 'test3',
88
+ 'followers',
89
+ other
90
+ )
91
+ const collection2 = await storage.getCollection('test3', 'followers')
92
+ assert.strictEqual(collection2.totalItems, 1)
93
+ const page = await storage.getCollectionPage('test3', 'followers', 1)
94
+ assert.strictEqual(page.items.length, 1)
95
+ assert.strictEqual(Array.from(page.items)[0].id, 'https://social.example/user/test2')
96
+ })
97
+ it('can remove from a collection', async () => {
98
+ await storage.removeFromCollection(
99
+ 'test3',
100
+ 'followers',
101
+ other
102
+ )
103
+ const collection2 = await storage.getCollection('test3', 'followers')
104
+ assert.strictEqual(collection2.totalItems, 0)
105
+ const page = await storage.getCollectionPage('test3', 'followers', 1)
106
+ assert.ok(!page.items)
107
+ })
108
+ it('can add a lot of items a collection', async () => {
109
+ for (let i = 0; i < 100; i++) {
110
+ const other = await as2.import({
111
+ id: `https://social.example/user/foo/note/${i}`,
112
+ type: 'Note',
113
+ content: `Hello World ${i}`
114
+ })
115
+ await storage.addToCollection(
116
+ 'test4',
117
+ 'liked',
118
+ other
119
+ )
120
+ }
121
+ const collection = await storage.getCollection('test4', 'liked')
122
+ assert.strictEqual(collection.totalItems, 100)
123
+ const page = await storage.getCollectionPage('test4', 'liked', 3)
124
+ assert.strictEqual(page.items.length, 20)
125
+ assert.strictEqual(page.next.id, 'https://activitypubbot.example/user/test4/liked/2')
126
+ })
127
+ it('can iterate over a collection', async () => {
128
+ const seen = new Set()
129
+ for await (const item of storage.items('test4', 'liked')) {
130
+ assert.ok(!(item.id in seen))
131
+ seen.add(item.id)
132
+ }
133
+ assert.strictEqual(seen.size, 100)
134
+ })
135
+ it('can add twice and remove once from a collection', async () => {
136
+ const other = await as2.import({
137
+ id: 'https://social.example/user/foo/note/200',
138
+ type: 'Note',
139
+ content: 'Hello World 200'
140
+ })
141
+ const other2 = await as2.import({
142
+ id: 'https://social.example/user/foo/note/201',
143
+ type: 'Note',
144
+ content: 'Hello World 201'
145
+ })
146
+ const collection = await storage.getCollection('test5', 'liked')
147
+ assert.strictEqual(collection.totalItems, 0)
148
+ await storage.addToCollection(
149
+ 'test5',
150
+ 'liked',
151
+ other
152
+ )
153
+ await storage.addToCollection(
154
+ 'test5',
155
+ 'liked',
156
+ other2
157
+ )
158
+ const collection2 = await storage.getCollection('test5', 'liked')
159
+ assert.strictEqual(collection2.totalItems, 2)
160
+ await storage.removeFromCollection(
161
+ 'test5',
162
+ 'liked',
163
+ other
164
+ )
165
+ const collection3 = await storage.getCollection('test5', 'liked')
166
+ assert.strictEqual(collection3.totalItems, 1)
167
+ })
168
+ it('can check if something is in the collection', async () => {
169
+ const other = await as2.import({
170
+ id: 'https://social.example/user/foo/note/300',
171
+ type: 'Note',
172
+ content: 'Hello World 300'
173
+ })
174
+ const other2 = await as2.import({
175
+ id: 'https://social.example/user/foo/note/301',
176
+ type: 'Note',
177
+ content: 'Hello World 301'
178
+ })
179
+ let collection = await storage.getCollection('test6', 'liked')
180
+ assert.strictEqual(collection.totalItems, 0)
181
+ await storage.addToCollection(
182
+ 'test6',
183
+ 'liked',
184
+ other
185
+ )
186
+ collection = await storage.getCollection('test6', 'liked')
187
+ assert.strictEqual(collection.totalItems, 1)
188
+ assert.ok(await storage.isInCollection(
189
+ 'test6',
190
+ 'liked',
191
+ other
192
+ ))
193
+ assert.ok(!await storage.isInCollection(
194
+ 'test6',
195
+ 'liked',
196
+ other2
197
+ ))
198
+ })
199
+
200
+ it('retains totalItems when we remove an absent object', async () => {
201
+ const other = await as2.import({
202
+ id: 'https://social.example/user/foo/note/400',
203
+ type: 'Note',
204
+ content: 'Hello World 400'
205
+ })
206
+ const other2 = await as2.import({
207
+ id: 'https://social.example/user/foo/note/401',
208
+ type: 'Note',
209
+ content: 'Hello World 401'
210
+ })
211
+ const other3 = await as2.import({
212
+ id: 'https://social.example/user/foo/note/402',
213
+ type: 'Note',
214
+ content: 'Hello World 402'
215
+ })
216
+ let collection = await storage.getCollection('test7', 'liked')
217
+ assert.strictEqual(collection.totalItems, 0)
218
+ await storage.addToCollection(
219
+ 'test7',
220
+ 'liked',
221
+ other
222
+ )
223
+ await storage.addToCollection(
224
+ 'test7',
225
+ 'liked',
226
+ other2
227
+ )
228
+ collection = await storage.getCollection('test7', 'liked')
229
+ assert.strictEqual(collection.totalItems, 2)
230
+ await storage.removeFromCollection(
231
+ 'test7',
232
+ 'liked',
233
+ other3
234
+ )
235
+ collection = await storage.getCollection('test7', 'liked')
236
+ assert.strictEqual(collection.totalItems, 2)
237
+ })
238
+ it('can get an actor with custom properties', async () => {
239
+ const props = {
240
+ name: 'Test User',
241
+ summary: 'A test user',
242
+ type: 'Person'
243
+ }
244
+ const actor = await storage.getActor('test8', props)
245
+ assert.ok(actor)
246
+ assert.ok(actor.id)
247
+ assert.ok(actor.inbox)
248
+ assert.ok(actor.outbox)
249
+ assert.ok(actor.followers)
250
+ assert.ok(actor.following)
251
+ assert.ok(actor.liked)
252
+ assert.strictEqual(actor.get('preferredUsername').first, 'test8')
253
+ assert.strictEqual(actor.name.get(), 'Test User')
254
+ assert.strictEqual(actor.summary.get(), 'A test user')
255
+ console.log(actor.type)
256
+ console.log(await actor.write())
257
+ assert.ok(Array.isArray(actor.type))
258
+ assert.ok(actor.type.includes(AS2_NS + 'Person'))
259
+ assert.ok(actor.type.includes(AS2_NS + 'Service'))
260
+ })
261
+ })
@@ -0,0 +1,17 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert'
3
+ import { makeApp } from '../lib/app.js'
4
+ import bots from './fixtures/bots.js'
5
+
6
+ describe('app', async () => {
7
+ const databaseUrl = 'sqlite::memory:'
8
+ const origin = 'https://activitypubbot.test'
9
+ let app = null
10
+ it('should be a function', async () => {
11
+ assert.strictEqual(typeof makeApp, 'function')
12
+ })
13
+ it('should return a function', async () => {
14
+ app = await makeApp(databaseUrl, origin, bots, 'silent')
15
+ assert.strictEqual(typeof app, 'function')
16
+ })
17
+ })