microfed 0.0.12 → 0.0.14

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.
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Live Federation Tests
3
+ * Tests against real Mastodon instances
4
+ *
5
+ * Run: node --test test/live.test.js
6
+ *
7
+ * Note: Requires network access. May fail if instances are down.
8
+ */
9
+
10
+ import { test, describe } from 'node:test'
11
+ import assert from 'node:assert'
12
+ import { lookup, resolve, getActorUrl } from '../src/webfinger.js'
13
+ import { parseActor, getActorId } from '../src/profile.js'
14
+
15
+ describe('Live WebFinger Lookup', { timeout: 10000 }, () => {
16
+ test('lookup mastodon.social account', async () => {
17
+ // Using Gargron (Mastodon creator) as a stable test account
18
+ const webfinger = await lookup('Gargron@mastodon.social')
19
+
20
+ assert.ok(webfinger.subject, 'missing subject')
21
+ assert.ok(webfinger.links, 'missing links')
22
+
23
+ const actorUrl = getActorUrl(webfinger)
24
+ assert.ok(actorUrl, 'no actor URL found')
25
+ assert.ok(actorUrl.includes('mastodon.social'), 'unexpected actor URL')
26
+ })
27
+
28
+ test('lookup returns valid JRD structure', async () => {
29
+ const webfinger = await lookup('Gargron@mastodon.social')
30
+
31
+ // Validate JRD structure
32
+ assert.ok(webfinger.subject.startsWith('acct:'))
33
+
34
+ const selfLink = webfinger.links.find(l =>
35
+ l.rel === 'self' && l.type === 'application/activity+json'
36
+ )
37
+ assert.ok(selfLink, 'missing ActivityPub self link')
38
+ assert.ok(selfLink.href.startsWith('https://'))
39
+ })
40
+ })
41
+
42
+ describe('Live Actor Fetch', { timeout: 10000 }, () => {
43
+ test('resolve mastodon.social actor', async () => {
44
+ const actor = await resolve('Gargron@mastodon.social')
45
+
46
+ // Validate required ActivityPub fields
47
+ assert.ok(actor.id, 'missing id')
48
+ assert.ok(actor.type, 'missing type')
49
+ assert.ok(actor.inbox, 'missing inbox')
50
+ assert.ok(actor.outbox, 'missing outbox')
51
+ assert.ok(actor.preferredUsername, 'missing preferredUsername')
52
+ })
53
+
54
+ test('actor has publicKey for signatures', async () => {
55
+ const actor = await resolve('Gargron@mastodon.social')
56
+
57
+ assert.ok(actor.publicKey, 'missing publicKey')
58
+ assert.ok(actor.publicKey.id, 'missing publicKey.id')
59
+ assert.ok(actor.publicKey.publicKeyPem, 'missing publicKeyPem')
60
+ assert.ok(
61
+ actor.publicKey.publicKeyPem.includes('BEGIN PUBLIC KEY'),
62
+ 'publicKeyPem not in PEM format'
63
+ )
64
+ })
65
+
66
+ test('actor type is Person', async () => {
67
+ const actor = await resolve('Gargron@mastodon.social')
68
+ assert.strictEqual(actor.type, 'Person')
69
+ })
70
+
71
+ test('can extract actor ID', async () => {
72
+ const actor = await resolve('Gargron@mastodon.social')
73
+ const id = getActorId(actor)
74
+
75
+ assert.ok(id.startsWith('https://'))
76
+ assert.ok(id.includes('mastodon.social'))
77
+ })
78
+ })
79
+
80
+ describe('Live Outbox Fetch', { timeout: 10000 }, () => {
81
+ test('can fetch actor outbox', async () => {
82
+ const actor = await resolve('Gargron@mastodon.social')
83
+
84
+ const response = await fetch(actor.outbox, {
85
+ headers: { 'Accept': 'application/activity+json' }
86
+ })
87
+
88
+ assert.strictEqual(response.status, 200)
89
+
90
+ const outbox = await response.json()
91
+ assert.ok(
92
+ outbox.type === 'OrderedCollection' || outbox.type === 'OrderedCollectionPage',
93
+ 'outbox must be OrderedCollection'
94
+ )
95
+ assert.ok(typeof outbox.totalItems === 'number', 'missing totalItems')
96
+ })
97
+ })
98
+
99
+ describe('Cross-instance Federation', { timeout: 15000 }, () => {
100
+ test('can lookup account on hachyderm.io', async () => {
101
+ // Test a different instance to verify federation lookup works
102
+ // Using a known active account
103
+ const webfinger = await lookup('nova@hachyderm.io')
104
+
105
+ assert.ok(webfinger.subject)
106
+ const actorUrl = getActorUrl(webfinger)
107
+ assert.ok(actorUrl.includes('hachyderm.io'))
108
+ })
109
+ })
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Mastodon Compatibility Tests
3
+ * Tests that our output matches Mastodon's expected format
4
+ *
5
+ * Run: node --test test/mastodon.test.js
6
+ */
7
+
8
+ import { test, describe } from 'node:test'
9
+ import assert from 'node:assert'
10
+ import { createActor } from '../src/profile.js'
11
+ import { generateKeypair } from '../src/auth.js'
12
+ import { createResponse } from '../src/webfinger.js'
13
+ import { createNote, wrapCreate } from '../src/outbox.js'
14
+
15
+ describe('Mastodon Actor Compatibility', () => {
16
+ test('actor has all required fields for Mastodon', () => {
17
+ const { publicKey } = generateKeypair()
18
+
19
+ const actor = createActor({
20
+ id: 'https://example.com/users/alice',
21
+ username: 'alice',
22
+ name: 'Alice',
23
+ summary: '<p>Hello!</p>',
24
+ publicKey
25
+ })
26
+
27
+ // Required by Mastodon
28
+ assert.ok(actor['@context'], 'missing @context')
29
+ assert.ok(actor.id, 'missing id')
30
+ assert.ok(actor.type, 'missing type')
31
+ assert.ok(actor.preferredUsername, 'missing preferredUsername')
32
+ assert.ok(actor.inbox, 'missing inbox')
33
+ assert.ok(actor.outbox, 'missing outbox')
34
+ assert.ok(actor.publicKey, 'missing publicKey')
35
+ assert.ok(actor.publicKey.id, 'missing publicKey.id')
36
+ assert.ok(actor.publicKey.owner, 'missing publicKey.owner')
37
+ assert.ok(actor.publicKey.publicKeyPem, 'missing publicKeyPem')
38
+ })
39
+
40
+ test('actor @context includes security namespace', () => {
41
+ const { publicKey } = generateKeypair()
42
+
43
+ const actor = createActor({
44
+ id: 'https://example.com/users/alice',
45
+ username: 'alice',
46
+ publicKey
47
+ })
48
+
49
+ assert.ok(
50
+ actor['@context'].includes('https://w3id.org/security/v1'),
51
+ '@context must include security namespace for publicKey'
52
+ )
53
+ })
54
+
55
+ test('publicKey.id follows Mastodon convention', () => {
56
+ const { publicKey } = generateKeypair()
57
+
58
+ const actor = createActor({
59
+ id: 'https://example.com/users/alice',
60
+ username: 'alice',
61
+ publicKey
62
+ })
63
+
64
+ // Mastodon expects keyId to be actor#main-key
65
+ assert.strictEqual(
66
+ actor.publicKey.id,
67
+ 'https://example.com/users/alice#main-key'
68
+ )
69
+ })
70
+
71
+ test('publicKey.owner matches actor id', () => {
72
+ const { publicKey } = generateKeypair()
73
+
74
+ const actor = createActor({
75
+ id: 'https://example.com/users/alice',
76
+ username: 'alice',
77
+ publicKey
78
+ })
79
+
80
+ assert.strictEqual(actor.publicKey.owner, actor.id)
81
+ })
82
+
83
+ test('inbox/outbox URLs follow convention', () => {
84
+ const actor = createActor({
85
+ id: 'https://example.com/users/alice',
86
+ username: 'alice'
87
+ })
88
+
89
+ assert.strictEqual(actor.inbox, 'https://example.com/users/alice/inbox')
90
+ assert.strictEqual(actor.outbox, 'https://example.com/users/alice/outbox')
91
+ })
92
+ })
93
+
94
+ describe('Mastodon WebFinger Compatibility', () => {
95
+ test('webfinger response has required fields', () => {
96
+ const response = createResponse(
97
+ 'alice@example.com',
98
+ 'https://example.com/users/alice'
99
+ )
100
+
101
+ assert.ok(response.subject, 'missing subject')
102
+ assert.ok(response.links, 'missing links')
103
+ assert.ok(Array.isArray(response.links), 'links must be array')
104
+ })
105
+
106
+ test('webfinger subject uses acct: scheme', () => {
107
+ const response = createResponse(
108
+ 'alice@example.com',
109
+ 'https://example.com/users/alice'
110
+ )
111
+
112
+ assert.ok(
113
+ response.subject.startsWith('acct:'),
114
+ 'subject must use acct: scheme'
115
+ )
116
+ })
117
+
118
+ test('webfinger has self link with ActivityPub type', () => {
119
+ const response = createResponse(
120
+ 'alice@example.com',
121
+ 'https://example.com/users/alice'
122
+ )
123
+
124
+ const selfLink = response.links.find(l => l.rel === 'self')
125
+
126
+ assert.ok(selfLink, 'missing self link')
127
+ assert.strictEqual(selfLink.type, 'application/activity+json')
128
+ assert.strictEqual(selfLink.href, 'https://example.com/users/alice')
129
+ })
130
+ })
131
+
132
+ describe('Mastodon Note Compatibility', () => {
133
+ test('note has required fields', () => {
134
+ const note = createNote({
135
+ actor: 'https://example.com/users/alice',
136
+ content: '<p>Hello, Mastodon!</p>'
137
+ })
138
+
139
+ assert.strictEqual(note.type, 'Note')
140
+ assert.ok(note.id, 'missing id')
141
+ assert.ok(note.attributedTo, 'missing attributedTo')
142
+ assert.ok(note.content, 'missing content')
143
+ assert.ok(note.published, 'missing published')
144
+ assert.ok(note.to, 'missing to')
145
+ assert.ok(note.cc, 'missing cc')
146
+ })
147
+
148
+ test('public note addresses Public collection', () => {
149
+ const note = createNote({
150
+ actor: 'https://example.com/users/alice',
151
+ content: '<p>Public post</p>',
152
+ public: true
153
+ })
154
+
155
+ assert.ok(
156
+ note.to.includes('https://www.w3.org/ns/activitystreams#Public'),
157
+ 'public note must address Public collection'
158
+ )
159
+ })
160
+
161
+ test('note cc includes followers collection', () => {
162
+ const note = createNote({
163
+ actor: 'https://example.com/users/alice',
164
+ content: '<p>Public post</p>',
165
+ public: true
166
+ })
167
+
168
+ assert.ok(
169
+ note.cc.some(c => c.includes('/followers')),
170
+ 'note cc should include followers collection'
171
+ )
172
+ })
173
+
174
+ test('published is ISO 8601 format', () => {
175
+ const note = createNote({
176
+ actor: 'https://example.com/users/alice',
177
+ content: '<p>Test</p>'
178
+ })
179
+
180
+ // Should be parseable as date
181
+ const date = new Date(note.published)
182
+ assert.ok(!isNaN(date.getTime()), 'published must be valid ISO 8601')
183
+ })
184
+ })
185
+
186
+ describe('Mastodon Create Activity Compatibility', () => {
187
+ test('Create activity wraps note correctly', () => {
188
+ const note = createNote({
189
+ actor: 'https://example.com/users/alice',
190
+ content: '<p>Hello!</p>'
191
+ })
192
+
193
+ const activity = wrapCreate('https://example.com/users/alice', note)
194
+
195
+ assert.strictEqual(activity.type, 'Create')
196
+ assert.strictEqual(activity.actor, 'https://example.com/users/alice')
197
+ assert.strictEqual(activity.object, note)
198
+ assert.ok(activity['@context'], 'missing @context')
199
+ assert.ok(activity.id, 'missing id')
200
+ assert.ok(activity.published, 'missing published')
201
+ })
202
+
203
+ test('Create activity copies addressing from object', () => {
204
+ const note = createNote({
205
+ actor: 'https://example.com/users/alice',
206
+ content: '<p>Hello!</p>',
207
+ public: true
208
+ })
209
+
210
+ const activity = wrapCreate('https://example.com/users/alice', note)
211
+
212
+ assert.deepStrictEqual(activity.to, note.to)
213
+ assert.deepStrictEqual(activity.cc, note.cc)
214
+ })
215
+ })
216
+
217
+ describe('Fetch Remote Mastodon Actor', async () => {
218
+ test('can parse real Mastodon actor format', async () => {
219
+ // Test against mastodon.social's format expectations
220
+ // This is what a Mastodon actor looks like
221
+ const mastodonActor = {
222
+ '@context': [
223
+ 'https://www.w3.org/ns/activitystreams',
224
+ 'https://w3id.org/security/v1'
225
+ ],
226
+ id: 'https://mastodon.social/users/test',
227
+ type: 'Person',
228
+ preferredUsername: 'test',
229
+ inbox: 'https://mastodon.social/users/test/inbox',
230
+ outbox: 'https://mastodon.social/users/test/outbox',
231
+ publicKey: {
232
+ id: 'https://mastodon.social/users/test#main-key',
233
+ owner: 'https://mastodon.social/users/test',
234
+ publicKeyPem: '-----BEGIN PUBLIC KEY-----\nMIIB...\n-----END PUBLIC KEY-----'
235
+ }
236
+ }
237
+
238
+ // Our code should be able to work with this format
239
+ assert.ok(mastodonActor.id)
240
+ assert.ok(mastodonActor.inbox)
241
+ assert.ok(mastodonActor.publicKey.publicKeyPem)
242
+ })
243
+ })
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Microfed Outbox Module Tests
3
+ * Run: node --test test/outbox.test.js
4
+ */
5
+
6
+ import { test, describe } from 'node:test'
7
+ import assert from 'node:assert'
8
+ import { createActivity, createNote, wrapCreate, createFollow, createUndo, createAccept } from '../src/outbox.js'
9
+
10
+ describe('createActivity', () => {
11
+ test('creates activity with required fields', () => {
12
+ const activity = createActivity({
13
+ type: 'Follow',
14
+ actor: 'https://example.com/users/alice',
15
+ object: 'https://example.com/users/bob'
16
+ })
17
+
18
+ assert.strictEqual(activity['@context'], 'https://www.w3.org/ns/activitystreams')
19
+ assert.strictEqual(activity.type, 'Follow')
20
+ assert.strictEqual(activity.actor, 'https://example.com/users/alice')
21
+ assert.strictEqual(activity.object, 'https://example.com/users/bob')
22
+ assert.ok(activity.id)
23
+ assert.ok(activity.published)
24
+ })
25
+
26
+ test('includes to and cc arrays', () => {
27
+ const activity = createActivity({
28
+ type: 'Create',
29
+ actor: 'https://example.com/users/alice',
30
+ object: { type: 'Note' },
31
+ to: ['https://www.w3.org/ns/activitystreams#Public'],
32
+ cc: ['https://example.com/users/alice/followers']
33
+ })
34
+
35
+ assert.deepStrictEqual(activity.to, ['https://www.w3.org/ns/activitystreams#Public'])
36
+ assert.deepStrictEqual(activity.cc, ['https://example.com/users/alice/followers'])
37
+ })
38
+
39
+ test('uses provided id', () => {
40
+ const activity = createActivity({
41
+ type: 'Like',
42
+ actor: 'https://example.com/users/alice',
43
+ object: 'https://example.com/notes/123',
44
+ id: 'https://example.com/activities/custom-id'
45
+ })
46
+
47
+ assert.strictEqual(activity.id, 'https://example.com/activities/custom-id')
48
+ })
49
+
50
+ test('throws when type is missing', () => {
51
+ assert.throws(() => {
52
+ createActivity({
53
+ actor: 'https://example.com/users/alice',
54
+ object: 'https://example.com/users/bob'
55
+ })
56
+ }, /type is required/)
57
+ })
58
+
59
+ test('throws when actor is missing', () => {
60
+ assert.throws(() => {
61
+ createActivity({
62
+ type: 'Follow',
63
+ object: 'https://example.com/users/bob'
64
+ })
65
+ }, /actor is required/)
66
+ })
67
+
68
+ test('throws when object is missing', () => {
69
+ assert.throws(() => {
70
+ createActivity({
71
+ type: 'Follow',
72
+ actor: 'https://example.com/users/alice'
73
+ })
74
+ }, /object is required/)
75
+ })
76
+ })
77
+
78
+ describe('createNote', () => {
79
+ test('creates note with required fields', () => {
80
+ const note = createNote({
81
+ actor: 'https://example.com/users/alice',
82
+ content: '<p>Hello, world!</p>'
83
+ })
84
+
85
+ assert.strictEqual(note.type, 'Note')
86
+ assert.strictEqual(note.attributedTo, 'https://example.com/users/alice')
87
+ assert.strictEqual(note.content, '<p>Hello, world!</p>')
88
+ assert.ok(note.id)
89
+ assert.ok(note.published)
90
+ })
91
+
92
+ test('public note has correct addressing', () => {
93
+ const note = createNote({
94
+ actor: 'https://example.com/users/alice',
95
+ content: '<p>Public post</p>',
96
+ public: true
97
+ })
98
+
99
+ assert.ok(note.to.includes('https://www.w3.org/ns/activitystreams#Public'))
100
+ assert.ok(note.cc.includes('https://example.com/users/alice/followers'))
101
+ })
102
+
103
+ test('private note has empty addressing', () => {
104
+ const note = createNote({
105
+ actor: 'https://example.com/users/alice',
106
+ content: '<p>Private post</p>',
107
+ public: false
108
+ })
109
+
110
+ assert.deepStrictEqual(note.to, [])
111
+ assert.deepStrictEqual(note.cc, [])
112
+ })
113
+
114
+ test('includes inReplyTo when provided', () => {
115
+ const note = createNote({
116
+ actor: 'https://example.com/users/alice',
117
+ content: '<p>This is a reply</p>',
118
+ inReplyTo: 'https://other.example/notes/456'
119
+ })
120
+
121
+ assert.strictEqual(note.inReplyTo, 'https://other.example/notes/456')
122
+ })
123
+ })
124
+
125
+ describe('wrapCreate', () => {
126
+ test('wraps note in Create activity', () => {
127
+ const note = {
128
+ type: 'Note',
129
+ id: 'https://example.com/notes/123',
130
+ content: '<p>Hello</p>',
131
+ to: ['https://www.w3.org/ns/activitystreams#Public'],
132
+ cc: ['https://example.com/users/alice/followers']
133
+ }
134
+
135
+ const activity = wrapCreate('https://example.com/users/alice', note)
136
+
137
+ assert.strictEqual(activity.type, 'Create')
138
+ assert.strictEqual(activity.actor, 'https://example.com/users/alice')
139
+ assert.strictEqual(activity.object, note)
140
+ assert.deepStrictEqual(activity.to, note.to)
141
+ assert.deepStrictEqual(activity.cc, note.cc)
142
+ })
143
+ })
144
+
145
+ describe('createFollow', () => {
146
+ test('creates Follow activity', () => {
147
+ const follow = createFollow(
148
+ 'https://example.com/users/alice',
149
+ 'https://other.example/users/bob'
150
+ )
151
+
152
+ assert.strictEqual(follow.type, 'Follow')
153
+ assert.strictEqual(follow.actor, 'https://example.com/users/alice')
154
+ assert.strictEqual(follow.object, 'https://other.example/users/bob')
155
+ assert.deepStrictEqual(follow.to, ['https://other.example/users/bob'])
156
+ })
157
+ })
158
+
159
+ describe('createUndo', () => {
160
+ test('creates Undo activity for Follow', () => {
161
+ const follow = {
162
+ type: 'Follow',
163
+ id: 'https://example.com/activities/123',
164
+ actor: 'https://example.com/users/alice',
165
+ object: 'https://other.example/users/bob',
166
+ to: ['https://other.example/users/bob'],
167
+ cc: []
168
+ }
169
+
170
+ const undo = createUndo('https://example.com/users/alice', follow)
171
+
172
+ assert.strictEqual(undo.type, 'Undo')
173
+ assert.strictEqual(undo.actor, 'https://example.com/users/alice')
174
+ assert.strictEqual(undo.object, follow)
175
+ assert.deepStrictEqual(undo.to, follow.to)
176
+ })
177
+ })
178
+
179
+ describe('createAccept', () => {
180
+ test('creates Accept activity for Follow', () => {
181
+ const follow = {
182
+ type: 'Follow',
183
+ id: 'https://other.example/activities/456',
184
+ actor: 'https://other.example/users/bob',
185
+ object: 'https://example.com/users/alice'
186
+ }
187
+
188
+ const accept = createAccept('https://example.com/users/alice', follow)
189
+
190
+ assert.strictEqual(accept.type, 'Accept')
191
+ assert.strictEqual(accept.actor, 'https://example.com/users/alice')
192
+ assert.strictEqual(accept.object, follow)
193
+ assert.deepStrictEqual(accept.to, ['https://other.example/users/bob'])
194
+ })
195
+ })