@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,463 @@
1
+ import { describe, before, after, it } from 'node:test'
2
+ import assert from 'node:assert'
3
+ import { Sequelize } from 'sequelize'
4
+ import { KeyStorage } from '../lib/keystorage.js'
5
+ import { nockSetup, nockSignature, nockKeyRotate, nockFormat } from './utils/nock.js'
6
+ import { HTTPSignature } from '../lib/httpsignature.js'
7
+ import { HTTPSignatureAuthenticator } from '../lib/httpsignatureauthenticator.js'
8
+ import Logger from 'pino'
9
+ import { Digester } from '../lib/digester.js'
10
+ import { RemoteKeyStorage } from '../lib/remotekeystorage.js'
11
+ import { ActivityPubClient } from '../lib/activitypubclient.js'
12
+ import { UrlFormatter } from '../lib/urlformatter.js'
13
+ import as2 from '../lib/activitystreams.js'
14
+
15
+ describe('HTTPSignatureAuthenticator', async () => {
16
+ const domain = 'activitypubbot.example'
17
+ const origin = `https://${domain}`
18
+ let authenticator = null
19
+ let logger = null
20
+ let signer = null
21
+ let digester = null
22
+ let remoteKeyStorage = null
23
+ let connection = null
24
+ const next = (err) => {
25
+ if (err) {
26
+ assert.fail(`Failed to authenticate: ${err.message}`)
27
+ } else {
28
+ assert.ok(true, 'Authenticated successfully')
29
+ }
30
+ }
31
+ const failNext = (err) => {
32
+ if (err) {
33
+ assert.ok(true, 'Failed successfully')
34
+ } else {
35
+ assert.fail('Passed through an incorrect request')
36
+ }
37
+ }
38
+ before(async () => {
39
+ logger = Logger({
40
+ level: 'silent'
41
+ })
42
+ connection = new Sequelize('sqlite::memory:', { logging: false })
43
+ await connection.authenticate()
44
+ signer = new HTTPSignature(logger)
45
+ digester = new Digester(logger)
46
+ const formatter = new UrlFormatter(origin)
47
+ const keyStorage = new KeyStorage(connection, logger)
48
+ await keyStorage.initialize()
49
+ const client = new ActivityPubClient(keyStorage, formatter, signer, digester, logger)
50
+ remoteKeyStorage = new RemoteKeyStorage(client, connection, logger)
51
+ await remoteKeyStorage.initialize()
52
+ nockSetup('social.example')
53
+ })
54
+
55
+ after(async () => {
56
+ await connection.close()
57
+ authenticator = null
58
+ digester = null
59
+ signer = null
60
+ remoteKeyStorage = null
61
+ })
62
+
63
+ it('can initialize', async () => {
64
+ authenticator = new HTTPSignatureAuthenticator(
65
+ remoteKeyStorage,
66
+ signer,
67
+ digester,
68
+ logger)
69
+ })
70
+
71
+ it('can authenticate a valid GET request', async () => {
72
+ const username = 'test'
73
+ const date = new Date().toUTCString()
74
+ const signature = await nockSignature({
75
+ url: `${origin}/user/ok/outbox`,
76
+ date,
77
+ username
78
+ })
79
+ const headers = {
80
+ date,
81
+ signature,
82
+ host: URL.parse(origin).host
83
+ }
84
+ const method = 'GET'
85
+ const originalUrl = '/user/ok/outbox'
86
+ const res = {
87
+
88
+ }
89
+ const req = {
90
+ headers,
91
+ originalUrl,
92
+ method,
93
+ get: function (name) {
94
+ return this.headers[name.toLowerCase()]
95
+ }
96
+ }
97
+ await authenticator.authenticate(req, res, next)
98
+ })
99
+
100
+ it('can authenticate a valid GET request with parameters', async () => {
101
+ const lname = 'ok'
102
+ const username = 'test'
103
+ const date = new Date().toUTCString()
104
+ const signature = await nockSignature({
105
+ url: `${origin}/.well-known/webfinger?resource=acct:${lname}@${domain}`,
106
+ date,
107
+ username
108
+ })
109
+ const headers = {
110
+ date,
111
+ signature,
112
+ host: URL.parse(origin).host
113
+ }
114
+ const method = 'GET'
115
+ const originalUrl = `/.well-known/webfinger?resource=acct:${lname}@${domain}`
116
+ const res = {
117
+
118
+ }
119
+ const req = {
120
+ headers,
121
+ originalUrl,
122
+ method,
123
+ get: function (name) {
124
+ return this.headers[name.toLowerCase()]
125
+ }
126
+ }
127
+ await authenticator.authenticate(req, res, next)
128
+ })
129
+
130
+ it('can authenticate a valid GET request after key rotation', async () => {
131
+ const username = 'test2'
132
+ const date = new Date().toUTCString()
133
+ const signature = await nockSignature({
134
+ url: `${origin}/user/ok/outbox`,
135
+ date,
136
+ username
137
+ })
138
+ const headers = {
139
+ date,
140
+ signature,
141
+ host: URL.parse(origin).host
142
+ }
143
+ const method = 'GET'
144
+ const originalUrl = '/user/ok/outbox'
145
+ const res = {
146
+
147
+ }
148
+ const req = {
149
+ headers,
150
+ originalUrl,
151
+ method,
152
+ get: function (name) {
153
+ return this.headers[name.toLowerCase()]
154
+ }
155
+ }
156
+ await authenticator.authenticate(req, res, next)
157
+ await nockKeyRotate(username)
158
+ const date2 = new Date().toUTCString()
159
+ const signature2 = await nockSignature({
160
+ url: `${origin}/user/ok/outbox`,
161
+ date: date2,
162
+ username
163
+ })
164
+ const headers2 = {
165
+ date: date2,
166
+ signature: signature2,
167
+ host: URL.parse(origin).host
168
+ }
169
+ const req2 = {
170
+ headers: headers2,
171
+ originalUrl,
172
+ method,
173
+ get: function (name) {
174
+ return this.headers[name.toLowerCase()]
175
+ }
176
+ }
177
+ await authenticator.authenticate(req2, res, next)
178
+ })
179
+
180
+ it('can authenticate a valid POST request', async () => {
181
+ const username = 'test3'
182
+ const type = 'Activity'
183
+ const activity = await as2.import({
184
+ id: nockFormat({ username, type }),
185
+ type
186
+ })
187
+ const rawBodyText = await activity.write()
188
+ const digest = await digester.digest(rawBodyText)
189
+ const date = new Date().toUTCString()
190
+ const method = 'POST'
191
+ const originalUrl = '/user/ok/inbox'
192
+ const signature = await nockSignature({
193
+ username,
194
+ url: `${origin}${originalUrl}`,
195
+ date,
196
+ digest,
197
+ method
198
+ })
199
+ const headers = {
200
+ date,
201
+ signature,
202
+ host: URL.parse(origin).host,
203
+ digest
204
+ }
205
+ const res = {
206
+
207
+ }
208
+ const req = {
209
+ headers,
210
+ originalUrl,
211
+ method,
212
+ get: function (name) {
213
+ return this.headers[name.toLowerCase()]
214
+ },
215
+ rawBodyText
216
+ }
217
+ await authenticator.authenticate(req, res, next)
218
+ })
219
+
220
+ it('skips a request that is not signed', async () => {
221
+ const date = new Date().toUTCString()
222
+ const method = 'GET'
223
+ const originalUrl = '/user/ok/outbox'
224
+ const headers = {
225
+ date,
226
+ host: URL.parse(origin).host
227
+ }
228
+ const res = {
229
+
230
+ }
231
+ const req = {
232
+ headers,
233
+ originalUrl,
234
+ method,
235
+ get: function (name) {
236
+ return this.headers[name.toLowerCase()]
237
+ }
238
+ }
239
+ await authenticator.authenticate(req, res, next)
240
+ })
241
+
242
+ it('can refuse a request signed with the wrong key', async () => {
243
+ const username = 'test'
244
+ const date = new Date().toUTCString()
245
+ const signature = await nockSignature({
246
+ url: `${origin}/user/ok/outbox`,
247
+ date,
248
+ username
249
+ })
250
+ const headers = {
251
+ date,
252
+ signature,
253
+ host: URL.parse(origin).host
254
+ }
255
+ const method = 'GET'
256
+ const originalUrl = '/user/ok/outbox'
257
+ const res = {
258
+
259
+ }
260
+ const req = {
261
+ headers,
262
+ originalUrl,
263
+ method,
264
+ get: function (name) {
265
+ return this.headers[name.toLowerCase()]
266
+ }
267
+ }
268
+ await authenticator.authenticate(req, res, next)
269
+ })
270
+
271
+ it('can refuse a request with a bad digest', async () => {
272
+ const username = 'test3'
273
+ const type = 'Activity'
274
+ const activity = await as2.import({
275
+ id: nockFormat({ username, type }),
276
+ type
277
+ })
278
+ const rawBodyText = await activity.write()
279
+ const digest = await digester.digest('This does not match the rawBodyText')
280
+ const date = new Date().toUTCString()
281
+ const method = 'POST'
282
+ const originalUrl = '/user/ok/inbox'
283
+ const signature = await nockSignature({
284
+ username,
285
+ url: `${origin}${originalUrl}`,
286
+ date,
287
+ digest,
288
+ method
289
+ })
290
+ const headers = {
291
+ date,
292
+ signature,
293
+ host: URL.parse(origin).host,
294
+ digest
295
+ }
296
+ const res = {
297
+
298
+ }
299
+ const req = {
300
+ headers,
301
+ originalUrl,
302
+ method,
303
+ get: function (name) {
304
+ return this.headers[name.toLowerCase()]
305
+ },
306
+ rawBodyText
307
+ }
308
+ await authenticator.authenticate(req, res, failNext)
309
+ })
310
+
311
+ it('can refuse a request with a missing digest', async () => {
312
+ const username = 'test3'
313
+ const type = 'Activity'
314
+ const activity = await as2.import({
315
+ id: nockFormat({ username, type }),
316
+ type
317
+ })
318
+ const rawBodyText = await activity.write()
319
+ const date = new Date().toUTCString()
320
+ const method = 'POST'
321
+ const originalUrl = '/user/ok/inbox'
322
+ const signature = await nockSignature({
323
+ username,
324
+ url: `${origin}${originalUrl}`,
325
+ date,
326
+ method
327
+ })
328
+ const headers = {
329
+ date,
330
+ signature,
331
+ host: URL.parse(origin).host
332
+ }
333
+ const res = {
334
+
335
+ }
336
+ const req = {
337
+ headers,
338
+ originalUrl,
339
+ method,
340
+ get: function (name) {
341
+ return this.headers[name.toLowerCase()]
342
+ },
343
+ rawBodyText
344
+ }
345
+ await authenticator.authenticate(req, res, failNext)
346
+ })
347
+
348
+ it('can refuse a request with a missing date', async () => {
349
+ const username = 'test'
350
+ const date = new Date().toUTCString()
351
+ const signature = await nockSignature({
352
+ url: `${origin}/user/ok/outbox`,
353
+ date,
354
+ username
355
+ })
356
+ const headers = {
357
+ signature,
358
+ host: URL.parse(origin).host
359
+ }
360
+ const method = 'GET'
361
+ const originalUrl = '/user/ok/outbox'
362
+ const res = {
363
+
364
+ }
365
+ const req = {
366
+ headers,
367
+ originalUrl,
368
+ method,
369
+ get: function (name) {
370
+ return this.headers[name.toLowerCase()]
371
+ }
372
+ }
373
+ await authenticator.authenticate(req, res, failNext)
374
+ })
375
+
376
+ it('can refuse a request with a badly formatted date', async () => {
377
+ const username = 'test'
378
+ const date = '3 Prairial CCXXXIII 14:00:35'
379
+ const signature = await nockSignature({
380
+ url: `${origin}/user/ok/outbox`,
381
+ date,
382
+ username
383
+ })
384
+ const headers = {
385
+ signature,
386
+ host: URL.parse(origin).host
387
+ }
388
+ const method = 'GET'
389
+ const originalUrl = '/user/ok/outbox'
390
+ const res = {
391
+
392
+ }
393
+ const req = {
394
+ headers,
395
+ originalUrl,
396
+ method,
397
+ get: function (name) {
398
+ return this.headers[name.toLowerCase()]
399
+ }
400
+ }
401
+ await authenticator.authenticate(req, res, failNext)
402
+ })
403
+
404
+ it('can refuse a request with a past date outside of the skew window', async () => {
405
+ const username = 'test'
406
+ // 10 days ago
407
+ const date = (new Date(Date.now() - 10 * 24 * 60 * 60 * 1000)).toUTCString()
408
+ logger.debug(date)
409
+ const signature = await nockSignature({
410
+ url: `${origin}/user/ok/outbox`,
411
+ date,
412
+ username
413
+ })
414
+ const headers = {
415
+ signature,
416
+ host: URL.parse(origin).host
417
+ }
418
+ const method = 'GET'
419
+ const originalUrl = '/user/ok/outbox'
420
+ const res = {
421
+
422
+ }
423
+ const req = {
424
+ headers,
425
+ originalUrl,
426
+ method,
427
+ get: function (name) {
428
+ return this.headers[name.toLowerCase()]
429
+ }
430
+ }
431
+ await authenticator.authenticate(req, res, failNext)
432
+ })
433
+
434
+ it('can refuse a request with a future date outside of the skew window', async () => {
435
+ const username = 'test'
436
+ // 10 days ago
437
+ const date = (new Date(Date.now() + 10 * 24 * 60 * 60 * 1000)).toUTCString()
438
+ logger.debug(date)
439
+ const signature = await nockSignature({
440
+ url: `${origin}/user/ok/outbox`,
441
+ date,
442
+ username
443
+ })
444
+ const headers = {
445
+ signature,
446
+ host: URL.parse(origin).host
447
+ }
448
+ const method = 'GET'
449
+ const originalUrl = '/user/ok/outbox'
450
+ const res = {
451
+
452
+ }
453
+ const req = {
454
+ headers,
455
+ originalUrl,
456
+ method,
457
+ get: function (name) {
458
+ return this.headers[name.toLowerCase()]
459
+ }
460
+ }
461
+ await authenticator.authenticate(req, res, failNext)
462
+ })
463
+ })
@@ -0,0 +1,89 @@
1
+ import { describe, before, after, it } from 'node:test'
2
+ import { KeyStorage } from '../lib/keystorage.js'
3
+ import assert from 'node:assert'
4
+ import { Sequelize } from 'sequelize'
5
+ import Logger from 'pino'
6
+
7
+ describe('KeyStorage', async () => {
8
+ let connection = null
9
+ let storage = null
10
+ let logger = null
11
+ let firstPublicKey = null
12
+ let firstPrivateKey = null
13
+ let secondPublicKey = null
14
+ let secondPrivateKey = null
15
+ before(async () => {
16
+ connection = new Sequelize('sqlite::memory:', { logging: false })
17
+ await connection.authenticate()
18
+ logger = new Logger({
19
+ level: 'silent'
20
+ })
21
+ })
22
+ after(async () => {
23
+ await connection.close()
24
+ connection = null
25
+ logger = null
26
+ })
27
+ it('can initialize', async () => {
28
+ storage = new KeyStorage(connection, logger)
29
+ await storage.initialize()
30
+ })
31
+ it('can get a public key', async () => {
32
+ firstPublicKey = await storage.getPublicKey('test1')
33
+ assert.ok(firstPublicKey)
34
+ assert.equal(typeof firstPublicKey, 'string')
35
+ assert.match(firstPublicKey, /^-----BEGIN PUBLIC KEY-----\n/)
36
+ assert.match(firstPublicKey, /-----END PUBLIC KEY-----\n$/)
37
+ })
38
+ it('can get a public key again', async () => {
39
+ secondPublicKey = await storage.getPublicKey('test1')
40
+ assert.ok(secondPublicKey)
41
+ assert.equal(typeof secondPublicKey, 'string')
42
+ assert.match(secondPublicKey, /^-----BEGIN PUBLIC KEY-----\n/)
43
+ assert.match(secondPublicKey, /-----END PUBLIC KEY-----\n$/)
44
+ assert.equal(firstPublicKey, secondPublicKey)
45
+ })
46
+ it('can get a private key after getting a public key', async () => {
47
+ const privateKey = await storage.getPrivateKey('test1')
48
+ assert.ok(privateKey)
49
+ assert.equal(typeof privateKey, 'string')
50
+ assert.match(privateKey, /^-----BEGIN PRIVATE KEY-----\n/)
51
+ assert.match(privateKey, /-----END PRIVATE KEY-----\n$/)
52
+ })
53
+ it('can get a private key', async () => {
54
+ firstPrivateKey = await storage.getPrivateKey('test2')
55
+ assert.ok(firstPrivateKey)
56
+ assert.equal(typeof firstPrivateKey, 'string')
57
+ assert.match(firstPrivateKey, /^-----BEGIN PRIVATE KEY-----\n/)
58
+ assert.match(firstPrivateKey, /-----END PRIVATE KEY-----\n$/)
59
+ })
60
+ it('can get a private key again', async () => {
61
+ secondPrivateKey = await storage.getPrivateKey('test2')
62
+ assert.ok(secondPrivateKey)
63
+ assert.equal(typeof secondPrivateKey, 'string')
64
+ assert.match(secondPrivateKey, /^-----BEGIN PRIVATE KEY-----\n/)
65
+ assert.match(secondPrivateKey, /-----END PRIVATE KEY-----\n$/)
66
+ assert.equal(firstPrivateKey, secondPrivateKey)
67
+ })
68
+ it('can get a public key after getting a private key', async () => {
69
+ const publicKey = await storage.getPublicKey('test2')
70
+ assert.ok(publicKey)
71
+ assert.equal(typeof publicKey, 'string')
72
+ assert.match(publicKey, /^-----BEGIN PUBLIC KEY-----\n/)
73
+ assert.match(publicKey, /-----END PUBLIC KEY-----\n$/)
74
+ })
75
+ it('can get distinct public keys for distinct bots', async () => {
76
+ const publicKey = await storage.getPublicKey('test1')
77
+ const publicKey2 = await storage.getPublicKey('test2')
78
+ assert.ok(publicKey)
79
+ assert.ok(publicKey2)
80
+ assert.notEqual(publicKey, publicKey2)
81
+ })
82
+ it('can get distinct private keys for distinct bots', async () => {
83
+ const privateKey = await storage.getPrivateKey('test1')
84
+ const privateKey2 = await storage.getPrivateKey('test2')
85
+ assert.ok(privateKey)
86
+ assert.ok(privateKey2)
87
+ assert.notEqual(privateKey, privateKey2)
88
+ })
89
+ })
@@ -0,0 +1,122 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert'
3
+ import { Sequelize } from 'sequelize'
4
+ import { Transformer } from '../lib/microsyntax.js'
5
+ import { UrlFormatter } from '../lib/urlformatter.js'
6
+ import { KeyStorage } from '../lib/keystorage.js'
7
+ import { ActivityPubClient } from '../lib/activitypubclient.js'
8
+ import { nockSetup } from './utils/nock.js'
9
+ import { HTTPSignature } from '../lib/httpsignature.js'
10
+ import Logger from 'pino'
11
+ import { Digester } from '../lib/digester.js'
12
+
13
+ const AS2 = 'https://www.w3.org/ns/activitystreams#'
14
+
15
+ describe('microsyntax', async () => {
16
+ const tagNamespace = 'https://tags.example/tag/'
17
+ const origin = 'https://activitypubbot.example'
18
+
19
+ nockSetup('social.example')
20
+
21
+ const logger = Logger({
22
+ level: 'silent'
23
+ })
24
+ const digester = new Digester(logger)
25
+ const connection = new Sequelize('sqlite::memory:', { logging: false })
26
+ await connection.authenticate()
27
+ const keyStorage = new KeyStorage(connection, logger)
28
+ await keyStorage.initialize()
29
+ const formatter = new UrlFormatter(origin)
30
+ const signer = new HTTPSignature(logger)
31
+ const client = new ActivityPubClient(keyStorage, formatter, signer, digester, logger)
32
+ const transformer = new Transformer(tagNamespace, client)
33
+
34
+ it('has transformer', () => {
35
+ assert.ok(transformer)
36
+ })
37
+
38
+ describe('transform tagless text', async () => {
39
+ const text = 'Hello, world!'
40
+ const { html } = await transformer.transform(text)
41
+ it('has output', () => {
42
+ assert.ok(html)
43
+ })
44
+ it('is the same as input', () => {
45
+ assert.equal(html, `<p>${text}</p>`)
46
+ })
47
+ })
48
+
49
+ describe('transform hashtag', async () => {
50
+ const text = 'Hello, World! #greeting'
51
+ const { html, tag } = await transformer.transform(text)
52
+ it('has html output', () => {
53
+ assert.ok(html)
54
+ })
55
+ it('has tag', () => {
56
+ assert.ok(tag)
57
+ })
58
+ it('has correct html', () => {
59
+ assert.equal(html, '<p>Hello, World! <a href="https://tags.example/tag/greeting">#greeting</a></p>')
60
+ })
61
+ it('has correct tag', () => {
62
+ assert.equal(tag.length, 1)
63
+ assert.equal(tag[0].type, AS2 + 'Hashtag')
64
+ assert.equal(tag[0].name, '#greeting')
65
+ assert.equal(tag[0].href, 'https://tags.example/tag/greeting')
66
+ })
67
+ })
68
+
69
+ describe('transform url', async () => {
70
+ const text = 'Please visit https://example.com for more information.'
71
+ const { html, tag } = await transformer.transform(text)
72
+ it('has html output', () => {
73
+ assert.ok(html)
74
+ })
75
+ it('has tag', () => {
76
+ assert.ok(tag)
77
+ })
78
+ it('has correct html', () => {
79
+ assert.equal(html, '<p>Please visit <a href="https://example.com">https://example.com</a> for more information.</p>')
80
+ })
81
+ it('has correct tag', () => {
82
+ assert.equal(tag.length, 0)
83
+ })
84
+ })
85
+
86
+ describe('transform url with fragment', async () => {
87
+ const text = 'Please visit https://example.com#fragment for more information.'
88
+ const { html, tag } = await transformer.transform(text)
89
+ it('has html output', () => {
90
+ assert.ok(html)
91
+ })
92
+ it('has tag', () => {
93
+ assert.ok(tag)
94
+ })
95
+ it('has correct html', () => {
96
+ assert.equal(html, '<p>Please visit <a href="https://example.com#fragment">https://example.com#fragment</a> for more information.</p>')
97
+ })
98
+ it('has correct tag', () => {
99
+ assert.equal(tag.length, 0)
100
+ })
101
+ })
102
+
103
+ describe('transform full mention', async () => {
104
+ const text = 'Hello, @world@social.example !'
105
+ const { html, tag } = await transformer.transform(text)
106
+ it('has html output', () => {
107
+ assert.ok(html)
108
+ })
109
+ it('has tag', () => {
110
+ assert.ok(tag)
111
+ })
112
+ it('has correct html', () => {
113
+ assert.equal(html, '<p>Hello, <a href="https://social.example/profile/world">@world@social.example</a> !</p>')
114
+ })
115
+ it('has correct tag', () => {
116
+ assert.equal(tag.length, 1)
117
+ assert.equal(tag[0].type, 'Mention')
118
+ assert.equal(tag[0].name, '@world@social.example')
119
+ assert.equal(tag[0].href, 'https://social.example/profile/world')
120
+ })
121
+ })
122
+ })