microfed 0.0.12 → 0.0.13

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,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
+ })
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Microfed Profile Module Tests
3
+ * Run: node --test test/profile.test.js
4
+ */
5
+
6
+ import { test, describe } from 'node:test'
7
+ import assert from 'node:assert'
8
+ import { createActor, createMinimalActor, parseActor, getActorId, buildActorUrl } from '../src/profile.js'
9
+
10
+ describe('createActor', () => {
11
+ test('creates actor with required fields', () => {
12
+ const actor = createActor({
13
+ id: 'https://example.com/users/alice',
14
+ username: 'alice'
15
+ })
16
+
17
+ assert.strictEqual(actor.id, 'https://example.com/users/alice')
18
+ assert.strictEqual(actor.type, 'Person')
19
+ assert.strictEqual(actor.preferredUsername, 'alice')
20
+ assert.strictEqual(actor.inbox, 'https://example.com/users/alice/inbox')
21
+ assert.strictEqual(actor.outbox, 'https://example.com/users/alice/outbox')
22
+ assert.strictEqual(actor.followers, 'https://example.com/users/alice/followers')
23
+ assert.strictEqual(actor.following, 'https://example.com/users/alice/following')
24
+ })
25
+
26
+ test('includes @context with ActivityStreams and security', () => {
27
+ const actor = createActor({
28
+ id: 'https://example.com/users/alice',
29
+ username: 'alice'
30
+ })
31
+
32
+ assert.ok(Array.isArray(actor['@context']))
33
+ assert.ok(actor['@context'].includes('https://www.w3.org/ns/activitystreams'))
34
+ assert.ok(actor['@context'].includes('https://w3id.org/security/v1'))
35
+ })
36
+
37
+ test('supports different actor types', () => {
38
+ const service = createActor({
39
+ id: 'https://example.com/bot',
40
+ username: 'bot',
41
+ type: 'Service'
42
+ })
43
+
44
+ assert.strictEqual(service.type, 'Service')
45
+ })
46
+
47
+ test('includes optional fields when provided', () => {
48
+ const actor = createActor({
49
+ id: 'https://example.com/users/alice',
50
+ username: 'alice',
51
+ name: 'Alice',
52
+ summary: '<p>Hello!</p>',
53
+ url: 'https://example.com/@alice'
54
+ })
55
+
56
+ assert.strictEqual(actor.name, 'Alice')
57
+ assert.strictEqual(actor.summary, '<p>Hello!</p>')
58
+ assert.strictEqual(actor.url, 'https://example.com/@alice')
59
+ })
60
+
61
+ test('formats icon as Image object', () => {
62
+ const actor = createActor({
63
+ id: 'https://example.com/users/alice',
64
+ username: 'alice',
65
+ icon: 'https://example.com/avatar.png'
66
+ })
67
+
68
+ assert.deepStrictEqual(actor.icon, {
69
+ type: 'Image',
70
+ url: 'https://example.com/avatar.png'
71
+ })
72
+ })
73
+
74
+ test('includes publicKey when provided', () => {
75
+ const publicKey = '-----BEGIN PUBLIC KEY-----\nMIIB...\n-----END PUBLIC KEY-----'
76
+ const actor = createActor({
77
+ id: 'https://example.com/users/alice',
78
+ username: 'alice',
79
+ publicKey
80
+ })
81
+
82
+ assert.strictEqual(actor.publicKey.id, 'https://example.com/users/alice#main-key')
83
+ assert.strictEqual(actor.publicKey.owner, 'https://example.com/users/alice')
84
+ assert.strictEqual(actor.publicKey.publicKeyPem, publicKey)
85
+ })
86
+
87
+ test('includes sharedInbox endpoint when provided', () => {
88
+ const actor = createActor({
89
+ id: 'https://example.com/users/alice',
90
+ username: 'alice',
91
+ sharedInbox: 'https://example.com/inbox'
92
+ })
93
+
94
+ assert.deepStrictEqual(actor.endpoints, {
95
+ sharedInbox: 'https://example.com/inbox'
96
+ })
97
+ })
98
+
99
+ test('throws when id is missing', () => {
100
+ assert.throws(() => {
101
+ createActor({ username: 'alice' })
102
+ }, /id is required/)
103
+ })
104
+
105
+ test('throws when username is missing', () => {
106
+ assert.throws(() => {
107
+ createActor({ id: 'https://example.com/users/alice' })
108
+ }, /username is required/)
109
+ })
110
+ })
111
+
112
+ describe('createMinimalActor', () => {
113
+ test('creates actor with just id and username', () => {
114
+ const actor = createMinimalActor(
115
+ 'https://example.com/users/bob',
116
+ 'bob'
117
+ )
118
+
119
+ assert.strictEqual(actor.id, 'https://example.com/users/bob')
120
+ assert.strictEqual(actor.preferredUsername, 'bob')
121
+ assert.strictEqual(actor.type, 'Person')
122
+ })
123
+ })
124
+
125
+ describe('parseActor', () => {
126
+ test('parses actor object', () => {
127
+ const input = {
128
+ id: 'https://example.com/users/alice',
129
+ inbox: 'https://example.com/users/alice/inbox'
130
+ }
131
+
132
+ const actor = parseActor(input)
133
+ assert.strictEqual(actor.id, input.id)
134
+ })
135
+
136
+ test('parses JSON string', () => {
137
+ const json = '{"id":"https://example.com/users/alice","inbox":"https://example.com/users/alice/inbox"}'
138
+
139
+ const actor = parseActor(json)
140
+ assert.strictEqual(actor.id, 'https://example.com/users/alice')
141
+ })
142
+
143
+ test('throws when id is missing', () => {
144
+ assert.throws(() => {
145
+ parseActor({ inbox: 'https://example.com/inbox' })
146
+ }, /missing id/)
147
+ })
148
+
149
+ test('throws when inbox is missing', () => {
150
+ assert.throws(() => {
151
+ parseActor({ id: 'https://example.com/users/alice' })
152
+ }, /missing inbox/)
153
+ })
154
+ })
155
+
156
+ describe('getActorId', () => {
157
+ test('returns string as-is', () => {
158
+ assert.strictEqual(
159
+ getActorId('https://example.com/users/alice'),
160
+ 'https://example.com/users/alice'
161
+ )
162
+ })
163
+
164
+ test('extracts id from object', () => {
165
+ assert.strictEqual(
166
+ getActorId({ id: 'https://example.com/users/alice' }),
167
+ 'https://example.com/users/alice'
168
+ )
169
+ })
170
+
171
+ test('extracts actor from activity', () => {
172
+ assert.strictEqual(
173
+ getActorId({ actor: 'https://example.com/users/alice' }),
174
+ 'https://example.com/users/alice'
175
+ )
176
+ })
177
+
178
+ test('handles nested actor object', () => {
179
+ assert.strictEqual(
180
+ getActorId({ actor: { id: 'https://example.com/users/alice' } }),
181
+ 'https://example.com/users/alice'
182
+ )
183
+ })
184
+
185
+ test('throws when cannot extract id', () => {
186
+ assert.throws(() => {
187
+ getActorId({})
188
+ }, /Cannot extract actor ID/)
189
+ })
190
+ })
191
+
192
+ describe('buildActorUrl', () => {
193
+ test('builds URL with default path', () => {
194
+ assert.strictEqual(
195
+ buildActorUrl('example.com', 'alice'),
196
+ 'https://example.com/users/alice'
197
+ )
198
+ })
199
+
200
+ test('builds URL with custom path', () => {
201
+ assert.strictEqual(
202
+ buildActorUrl('example.com', 'alice', '/@{username}'),
203
+ 'https://example.com/@alice'
204
+ )
205
+ })
206
+
207
+ test('handles subdomain', () => {
208
+ assert.strictEqual(
209
+ buildActorUrl('social.example.org', 'bob'),
210
+ 'https://social.example.org/users/bob'
211
+ )
212
+ })
213
+ })