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.
- package/README.md +194 -65
- package/example/server.js +252 -0
- package/index.js +36 -0
- package/package.json +22 -4
- package/src/auth.js +152 -0
- package/src/inbox.js +188 -0
- package/src/outbox.js +268 -0
- package/src/profile.js +114 -0
- package/src/webfinger.js +182 -0
- package/test/auth.test.js +218 -0
- package/test/inbox.test.js +135 -0
- package/test/live.test.js +109 -0
- package/test/mastodon.test.js +243 -0
- package/test/outbox.test.js +195 -0
- package/test/profile.test.js +213 -0
- package/test/webfinger.test.js +139 -0
package/src/profile.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Microfed Profile Module
|
|
3
|
+
* Minimal ActivityPub actor/profile generation
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const AS_CONTEXT = 'https://www.w3.org/ns/activitystreams'
|
|
7
|
+
const SECURITY_CONTEXT = 'https://w3id.org/security/v1'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Create an ActivityPub actor profile
|
|
11
|
+
* @param {Object} options - Actor configuration
|
|
12
|
+
* @param {string} options.id - Actor's unique URL (required)
|
|
13
|
+
* @param {string} options.type - Actor type: Person, Service, Group, Application, Organization
|
|
14
|
+
* @param {string} options.username - preferredUsername (handle)
|
|
15
|
+
* @param {string} [options.name] - Display name
|
|
16
|
+
* @param {string} [options.summary] - Bio/description (HTML allowed)
|
|
17
|
+
* @param {string} [options.inbox] - Inbox URL (defaults to {id}/inbox)
|
|
18
|
+
* @param {string} [options.outbox] - Outbox URL (defaults to {id}/outbox)
|
|
19
|
+
* @param {string} [options.publicKey] - PEM-encoded public key
|
|
20
|
+
* @param {string} [options.icon] - Avatar URL
|
|
21
|
+
* @param {string} [options.image] - Header/banner URL
|
|
22
|
+
* @returns {Object} ActivityPub Actor object
|
|
23
|
+
*/
|
|
24
|
+
export function createActor(options) {
|
|
25
|
+
const { id, type = 'Person', username } = options
|
|
26
|
+
|
|
27
|
+
if (!id) throw new Error('Actor id is required')
|
|
28
|
+
if (!username) throw new Error('Actor username is required')
|
|
29
|
+
|
|
30
|
+
const actor = {
|
|
31
|
+
'@context': [AS_CONTEXT, SECURITY_CONTEXT],
|
|
32
|
+
type,
|
|
33
|
+
id,
|
|
34
|
+
preferredUsername: username,
|
|
35
|
+
inbox: options.inbox || `${id}/inbox`,
|
|
36
|
+
outbox: options.outbox || `${id}/outbox`,
|
|
37
|
+
followers: options.followers || `${id}/followers`,
|
|
38
|
+
following: options.following || `${id}/following`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Optional fields
|
|
42
|
+
if (options.name) actor.name = options.name
|
|
43
|
+
if (options.summary) actor.summary = options.summary
|
|
44
|
+
if (options.url) actor.url = options.url
|
|
45
|
+
if (options.icon) actor.icon = { type: 'Image', url: options.icon }
|
|
46
|
+
if (options.image) actor.image = { type: 'Image', url: options.image }
|
|
47
|
+
|
|
48
|
+
// Public key for HTTP signatures
|
|
49
|
+
if (options.publicKey) {
|
|
50
|
+
actor.publicKey = {
|
|
51
|
+
id: `${id}#main-key`,
|
|
52
|
+
owner: id,
|
|
53
|
+
publicKeyPem: options.publicKey
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Shared inbox for efficient delivery
|
|
58
|
+
if (options.sharedInbox) {
|
|
59
|
+
actor.endpoints = { sharedInbox: options.sharedInbox }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return actor
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Create a minimal actor (just the essentials)
|
|
67
|
+
* @param {string} id - Actor URL
|
|
68
|
+
* @param {string} username - Handle
|
|
69
|
+
* @returns {Object} Minimal actor object
|
|
70
|
+
*/
|
|
71
|
+
export function createMinimalActor(id, username) {
|
|
72
|
+
return createActor({ id, username })
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Parse an actor from JSON
|
|
77
|
+
* @param {string|Object} json - JSON string or object
|
|
78
|
+
* @returns {Object} Parsed actor
|
|
79
|
+
*/
|
|
80
|
+
export function parseActor(json) {
|
|
81
|
+
const actor = typeof json === 'string' ? JSON.parse(json) : json
|
|
82
|
+
|
|
83
|
+
// Validate required fields
|
|
84
|
+
if (!actor.id) throw new Error('Actor missing id')
|
|
85
|
+
if (!actor.inbox) throw new Error('Actor missing inbox')
|
|
86
|
+
|
|
87
|
+
return actor
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Extract actor ID from various formats
|
|
92
|
+
* @param {string|Object} actor - Actor URL, object, or activity
|
|
93
|
+
* @returns {string} Actor ID URL
|
|
94
|
+
*/
|
|
95
|
+
export function getActorId(actor) {
|
|
96
|
+
if (typeof actor === 'string') return actor
|
|
97
|
+
if (actor.id) return actor.id
|
|
98
|
+
if (actor.actor) return getActorId(actor.actor)
|
|
99
|
+
throw new Error('Cannot extract actor ID')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Build actor URL from domain and username
|
|
104
|
+
* @param {string} domain - Domain (e.g., example.com)
|
|
105
|
+
* @param {string} username - Username/handle
|
|
106
|
+
* @param {string} [path] - URL path pattern (default: /users/{username})
|
|
107
|
+
* @returns {string} Full actor URL
|
|
108
|
+
*/
|
|
109
|
+
export function buildActorUrl(domain, username, path = '/users/{username}') {
|
|
110
|
+
const actorPath = path.replace('{username}', username)
|
|
111
|
+
return `https://${domain}${actorPath}`
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export default { createActor, createMinimalActor, parseActor, getActorId, buildActorUrl }
|
package/src/webfinger.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Microfed WebFinger Module
|
|
3
|
+
* Actor discovery via WebFinger protocol
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const WEBFINGER_PATH = '/.well-known/webfinger'
|
|
7
|
+
const ACTIVITYPUB_TYPE = 'application/activity+json'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Create WebFinger response for an actor
|
|
11
|
+
* @param {string} account - Account identifier (user@domain)
|
|
12
|
+
* @param {string} actorUrl - Actor's ActivityPub URL
|
|
13
|
+
* @param {Object} [options] - Additional options
|
|
14
|
+
* @param {string} [options.profileUrl] - HTML profile page URL
|
|
15
|
+
* @param {Array} [options.aliases] - Alternative identifiers
|
|
16
|
+
* @returns {Object} WebFinger JRD response
|
|
17
|
+
*/
|
|
18
|
+
export function createResponse(account, actorUrl, options = {}) {
|
|
19
|
+
const response = {
|
|
20
|
+
subject: `acct:${account}`,
|
|
21
|
+
links: [
|
|
22
|
+
{
|
|
23
|
+
rel: 'self',
|
|
24
|
+
type: ACTIVITYPUB_TYPE,
|
|
25
|
+
href: actorUrl
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Add HTML profile link
|
|
31
|
+
if (options.profileUrl) {
|
|
32
|
+
response.links.push({
|
|
33
|
+
rel: 'http://webfinger.net/rel/profile-page',
|
|
34
|
+
type: 'text/html',
|
|
35
|
+
href: options.profileUrl
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Add aliases
|
|
40
|
+
if (options.aliases) {
|
|
41
|
+
response.aliases = options.aliases
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return response
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Parse WebFinger resource query
|
|
49
|
+
* @param {string} resource - Resource query (acct:user@domain or URL)
|
|
50
|
+
* @returns {Object|null} { username, domain } or null
|
|
51
|
+
*/
|
|
52
|
+
export function parseResource(resource) {
|
|
53
|
+
if (!resource) return null
|
|
54
|
+
|
|
55
|
+
// Handle acct: URI
|
|
56
|
+
if (resource.startsWith('acct:')) {
|
|
57
|
+
const account = resource.slice(5)
|
|
58
|
+
const [username, domain] = account.split('@')
|
|
59
|
+
if (username && domain) {
|
|
60
|
+
return { username, domain }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Handle URL
|
|
65
|
+
if (resource.startsWith('https://') || resource.startsWith('http://')) {
|
|
66
|
+
try {
|
|
67
|
+
const url = new URL(resource)
|
|
68
|
+
const match = url.pathname.match(/\/users\/([^/]+)/)
|
|
69
|
+
if (match) {
|
|
70
|
+
return { username: match[1], domain: url.host }
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Lookup actor via WebFinger
|
|
82
|
+
* @param {string} account - Account to lookup (user@domain)
|
|
83
|
+
* @returns {Promise<Object>} WebFinger response
|
|
84
|
+
*/
|
|
85
|
+
export async function lookup(account) {
|
|
86
|
+
// Extract domain
|
|
87
|
+
const [, domain] = account.includes('@') ? account.split('@') : [null, account]
|
|
88
|
+
if (!domain) throw new Error('Invalid account format')
|
|
89
|
+
|
|
90
|
+
const resource = account.includes('@') ? `acct:${account}` : account
|
|
91
|
+
const url = `https://${domain}${WEBFINGER_PATH}?resource=${encodeURIComponent(resource)}`
|
|
92
|
+
|
|
93
|
+
const response = await fetch(url, {
|
|
94
|
+
headers: { 'Accept': 'application/jrd+json, application/json' }
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
if (!response.ok) {
|
|
98
|
+
throw new Error(`WebFinger lookup failed: ${response.status}`)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return response.json()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Get actor URL from WebFinger response
|
|
106
|
+
* @param {Object} webfinger - WebFinger JRD response
|
|
107
|
+
* @returns {string|null} Actor URL or null
|
|
108
|
+
*/
|
|
109
|
+
export function getActorUrl(webfinger) {
|
|
110
|
+
if (!webfinger.links) return null
|
|
111
|
+
|
|
112
|
+
const link = webfinger.links.find(l =>
|
|
113
|
+
l.rel === 'self' && l.type === ACTIVITYPUB_TYPE
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
return link?.href || null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Resolve account to actor object
|
|
121
|
+
* @param {string} account - Account (user@domain)
|
|
122
|
+
* @returns {Promise<Object>} Actor object
|
|
123
|
+
*/
|
|
124
|
+
export async function resolve(account) {
|
|
125
|
+
const webfinger = await lookup(account)
|
|
126
|
+
const actorUrl = getActorUrl(webfinger)
|
|
127
|
+
|
|
128
|
+
if (!actorUrl) {
|
|
129
|
+
throw new Error('No ActivityPub actor found')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const response = await fetch(actorUrl, {
|
|
133
|
+
headers: { 'Accept': ACTIVITYPUB_TYPE }
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
if (!response.ok) {
|
|
137
|
+
throw new Error(`Actor fetch failed: ${response.status}`)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return response.json()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Create WebFinger request handler
|
|
145
|
+
* @param {Function} findActor - Function(username) => { actorUrl, profileUrl? }
|
|
146
|
+
* @returns {Function} Handler(req) => Response
|
|
147
|
+
*/
|
|
148
|
+
export function createHandler(findActor) {
|
|
149
|
+
return async (req) => {
|
|
150
|
+
const url = new URL(req.url)
|
|
151
|
+
const resource = url.searchParams.get('resource')
|
|
152
|
+
|
|
153
|
+
if (!resource) {
|
|
154
|
+
return new Response('Missing resource parameter', { status: 400 })
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const parsed = parseResource(resource)
|
|
158
|
+
if (!parsed) {
|
|
159
|
+
return new Response('Invalid resource format', { status: 400 })
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const actor = await findActor(parsed.username)
|
|
163
|
+
if (!actor) {
|
|
164
|
+
return new Response('Not found', { status: 404 })
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const response = createResponse(
|
|
168
|
+
`${parsed.username}@${parsed.domain}`,
|
|
169
|
+
actor.actorUrl,
|
|
170
|
+
{ profileUrl: actor.profileUrl }
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
return new Response(JSON.stringify(response), {
|
|
174
|
+
headers: {
|
|
175
|
+
'Content-Type': 'application/jrd+json',
|
|
176
|
+
'Access-Control-Allow-Origin': '*'
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export default { createResponse, parseResource, lookup, getActorUrl, resolve, createHandler }
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Microfed Auth Module Tests
|
|
3
|
+
* Run: node --test test/auth.test.js
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { test, describe } from 'node:test'
|
|
7
|
+
import assert from 'node:assert'
|
|
8
|
+
import { generateKeypair, sign, verify, parseSignatureHeader, digest, verifyDigest } from '../src/auth.js'
|
|
9
|
+
|
|
10
|
+
describe('generateKeypair', () => {
|
|
11
|
+
test('generates valid RSA keypair', () => {
|
|
12
|
+
const { publicKey, privateKey } = generateKeypair()
|
|
13
|
+
|
|
14
|
+
assert.ok(publicKey.includes('-----BEGIN PUBLIC KEY-----'))
|
|
15
|
+
assert.ok(publicKey.includes('-----END PUBLIC KEY-----'))
|
|
16
|
+
assert.ok(privateKey.includes('-----BEGIN PRIVATE KEY-----'))
|
|
17
|
+
assert.ok(privateKey.includes('-----END PRIVATE KEY-----'))
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('generates unique keypairs', () => {
|
|
21
|
+
const pair1 = generateKeypair()
|
|
22
|
+
const pair2 = generateKeypair()
|
|
23
|
+
|
|
24
|
+
assert.notStrictEqual(pair1.publicKey, pair2.publicKey)
|
|
25
|
+
assert.notStrictEqual(pair1.privateKey, pair2.privateKey)
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('sign', () => {
|
|
30
|
+
test('returns required headers', () => {
|
|
31
|
+
const { privateKey } = generateKeypair()
|
|
32
|
+
|
|
33
|
+
const headers = sign({
|
|
34
|
+
privateKey,
|
|
35
|
+
keyId: 'https://example.com/users/alice#main-key',
|
|
36
|
+
method: 'POST',
|
|
37
|
+
url: 'https://remote.example/users/bob/inbox',
|
|
38
|
+
body: '{"type":"Follow"}'
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
assert.ok(headers.Date)
|
|
42
|
+
assert.ok(headers.Signature)
|
|
43
|
+
assert.ok(headers.Digest)
|
|
44
|
+
assert.ok(headers.Digest.startsWith('SHA-256='))
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('signature header has required parts', () => {
|
|
48
|
+
const { privateKey } = generateKeypair()
|
|
49
|
+
|
|
50
|
+
const headers = sign({
|
|
51
|
+
privateKey,
|
|
52
|
+
keyId: 'https://example.com/users/alice#main-key',
|
|
53
|
+
method: 'POST',
|
|
54
|
+
url: 'https://remote.example/users/bob/inbox',
|
|
55
|
+
body: '{}'
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const parsed = parseSignatureHeader(headers.Signature)
|
|
59
|
+
assert.ok(parsed.keyId)
|
|
60
|
+
assert.ok(parsed.algorithm)
|
|
61
|
+
assert.ok(parsed.headers)
|
|
62
|
+
assert.ok(parsed.signature)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('GET request without body omits digest', () => {
|
|
66
|
+
const { privateKey } = generateKeypair()
|
|
67
|
+
|
|
68
|
+
const headers = sign({
|
|
69
|
+
privateKey,
|
|
70
|
+
keyId: 'https://example.com/users/alice#main-key',
|
|
71
|
+
method: 'GET',
|
|
72
|
+
url: 'https://remote.example/users/bob'
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
assert.ok(headers.Date)
|
|
76
|
+
assert.ok(headers.Signature)
|
|
77
|
+
assert.strictEqual(headers.Digest, undefined)
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('verify', () => {
|
|
82
|
+
test('verifies valid signature', () => {
|
|
83
|
+
const { publicKey, privateKey } = generateKeypair()
|
|
84
|
+
const url = 'https://remote.example/users/bob/inbox'
|
|
85
|
+
const body = '{"type":"Follow","actor":"https://example.com/users/alice"}'
|
|
86
|
+
|
|
87
|
+
const headers = sign({
|
|
88
|
+
privateKey,
|
|
89
|
+
keyId: 'https://example.com/users/alice#main-key',
|
|
90
|
+
method: 'POST',
|
|
91
|
+
url,
|
|
92
|
+
body
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
const valid = verify({
|
|
96
|
+
publicKey,
|
|
97
|
+
signature: headers.Signature,
|
|
98
|
+
method: 'POST',
|
|
99
|
+
path: '/users/bob/inbox',
|
|
100
|
+
headers: {
|
|
101
|
+
host: 'remote.example',
|
|
102
|
+
date: headers.Date,
|
|
103
|
+
digest: headers.Digest
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
assert.strictEqual(valid, true)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('rejects tampered signature', () => {
|
|
111
|
+
const { publicKey, privateKey } = generateKeypair()
|
|
112
|
+
|
|
113
|
+
const headers = sign({
|
|
114
|
+
privateKey,
|
|
115
|
+
keyId: 'https://example.com/users/alice#main-key',
|
|
116
|
+
method: 'POST',
|
|
117
|
+
url: 'https://remote.example/inbox',
|
|
118
|
+
body: '{}'
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
// Tamper with signature
|
|
122
|
+
const tampered = headers.Signature.replace(/signature="[^"]+"/, 'signature="dGFtcGVyZWQ="')
|
|
123
|
+
|
|
124
|
+
const valid = verify({
|
|
125
|
+
publicKey,
|
|
126
|
+
signature: tampered,
|
|
127
|
+
method: 'POST',
|
|
128
|
+
path: '/inbox',
|
|
129
|
+
headers: {
|
|
130
|
+
host: 'remote.example',
|
|
131
|
+
date: headers.Date,
|
|
132
|
+
digest: headers.Digest
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
assert.strictEqual(valid, false)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('rejects wrong public key', () => {
|
|
140
|
+
const sender = generateKeypair()
|
|
141
|
+
const other = generateKeypair()
|
|
142
|
+
|
|
143
|
+
const headers = sign({
|
|
144
|
+
privateKey: sender.privateKey,
|
|
145
|
+
keyId: 'https://example.com/users/alice#main-key',
|
|
146
|
+
method: 'POST',
|
|
147
|
+
url: 'https://remote.example/inbox',
|
|
148
|
+
body: '{}'
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const valid = verify({
|
|
152
|
+
publicKey: other.publicKey, // Wrong key
|
|
153
|
+
signature: headers.Signature,
|
|
154
|
+
method: 'POST',
|
|
155
|
+
path: '/inbox',
|
|
156
|
+
headers: {
|
|
157
|
+
host: 'remote.example',
|
|
158
|
+
date: headers.Date,
|
|
159
|
+
digest: headers.Digest
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
assert.strictEqual(valid, false)
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
describe('parseSignatureHeader', () => {
|
|
168
|
+
test('parses valid signature header', () => {
|
|
169
|
+
const header = 'keyId="https://example.com/users/alice#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="abc123"'
|
|
170
|
+
|
|
171
|
+
const parsed = parseSignatureHeader(header)
|
|
172
|
+
|
|
173
|
+
assert.strictEqual(parsed.keyId, 'https://example.com/users/alice#main-key')
|
|
174
|
+
assert.strictEqual(parsed.algorithm, 'rsa-sha256')
|
|
175
|
+
assert.strictEqual(parsed.headers, '(request-target) host date')
|
|
176
|
+
assert.strictEqual(parsed.signature, 'abc123')
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('returns null for invalid header', () => {
|
|
180
|
+
assert.strictEqual(parseSignatureHeader('invalid'), null)
|
|
181
|
+
assert.strictEqual(parseSignatureHeader(''), null)
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
describe('digest', () => {
|
|
186
|
+
test('creates SHA-256 digest', () => {
|
|
187
|
+
const result = digest('{"test":true}')
|
|
188
|
+
|
|
189
|
+
assert.ok(result.startsWith('SHA-256='))
|
|
190
|
+
assert.strictEqual(result.length, 8 + 44) // "SHA-256=" + base64
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
test('same input produces same digest', () => {
|
|
194
|
+
const body = '{"type":"Create"}'
|
|
195
|
+
|
|
196
|
+
assert.strictEqual(digest(body), digest(body))
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test('different input produces different digest', () => {
|
|
200
|
+
assert.notStrictEqual(digest('a'), digest('b'))
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
describe('verifyDigest', () => {
|
|
205
|
+
test('verifies matching digest', () => {
|
|
206
|
+
const body = '{"type":"Follow"}'
|
|
207
|
+
const digestHeader = digest(body)
|
|
208
|
+
|
|
209
|
+
assert.strictEqual(verifyDigest(body, digestHeader), true)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
test('rejects non-matching digest', () => {
|
|
213
|
+
const body = '{"type":"Follow"}'
|
|
214
|
+
const wrongDigest = digest('{"type":"Create"}')
|
|
215
|
+
|
|
216
|
+
assert.strictEqual(verifyDigest(body, wrongDigest), false)
|
|
217
|
+
})
|
|
218
|
+
})
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Microfed Inbox Module Tests
|
|
3
|
+
* Run: node --test test/inbox.test.js
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { test, describe } from 'node:test'
|
|
7
|
+
import assert from 'node:assert'
|
|
8
|
+
import { getActor, getObject, isAddressedTo, isPublic } from '../src/inbox.js'
|
|
9
|
+
|
|
10
|
+
describe('getActor', () => {
|
|
11
|
+
test('extracts actor string', () => {
|
|
12
|
+
const activity = {
|
|
13
|
+
type: 'Create',
|
|
14
|
+
actor: 'https://example.com/users/alice'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
assert.strictEqual(getActor(activity), 'https://example.com/users/alice')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('extracts actor from object', () => {
|
|
21
|
+
const activity = {
|
|
22
|
+
type: 'Create',
|
|
23
|
+
actor: { id: 'https://example.com/users/alice' }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
assert.strictEqual(getActor(activity), 'https://example.com/users/alice')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('throws when actor missing', () => {
|
|
30
|
+
assert.throws(() => {
|
|
31
|
+
getActor({ type: 'Create' })
|
|
32
|
+
}, /Cannot extract actor/)
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('getObject', () => {
|
|
37
|
+
test('returns object directly', () => {
|
|
38
|
+
const activity = {
|
|
39
|
+
type: 'Create',
|
|
40
|
+
object: { type: 'Note', content: 'Hello' }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
assert.deepStrictEqual(getObject(activity), { type: 'Note', content: 'Hello' })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('returns object ID string', () => {
|
|
47
|
+
const activity = {
|
|
48
|
+
type: 'Like',
|
|
49
|
+
object: 'https://example.com/notes/123'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
assert.strictEqual(getObject(activity), 'https://example.com/notes/123')
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('isAddressedTo', () => {
|
|
57
|
+
test('returns true when in to field', () => {
|
|
58
|
+
const activity = {
|
|
59
|
+
type: 'Create',
|
|
60
|
+
to: ['https://example.com/users/bob']
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
assert.strictEqual(isAddressedTo(activity, 'https://example.com/users/bob'), true)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('returns true when in cc field', () => {
|
|
67
|
+
const activity = {
|
|
68
|
+
type: 'Create',
|
|
69
|
+
to: ['https://www.w3.org/ns/activitystreams#Public'],
|
|
70
|
+
cc: ['https://example.com/users/bob']
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
assert.strictEqual(isAddressedTo(activity, 'https://example.com/users/bob'), true)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('returns false when not addressed', () => {
|
|
77
|
+
const activity = {
|
|
78
|
+
type: 'Create',
|
|
79
|
+
to: ['https://example.com/users/alice']
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
assert.strictEqual(isAddressedTo(activity, 'https://example.com/users/bob'), false)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('handles missing to/cc fields', () => {
|
|
86
|
+
const activity = { type: 'Create' }
|
|
87
|
+
|
|
88
|
+
assert.strictEqual(isAddressedTo(activity, 'https://example.com/users/bob'), false)
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe('isPublic', () => {
|
|
93
|
+
test('returns true for Public in to', () => {
|
|
94
|
+
const activity = {
|
|
95
|
+
type: 'Create',
|
|
96
|
+
to: ['https://www.w3.org/ns/activitystreams#Public']
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
assert.strictEqual(isPublic(activity), true)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('returns true for Public in cc', () => {
|
|
103
|
+
const activity = {
|
|
104
|
+
type: 'Create',
|
|
105
|
+
to: ['https://example.com/users/alice/followers'],
|
|
106
|
+
cc: ['https://www.w3.org/ns/activitystreams#Public']
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
assert.strictEqual(isPublic(activity), true)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('returns true for short Public form', () => {
|
|
113
|
+
const activity = {
|
|
114
|
+
type: 'Create',
|
|
115
|
+
to: ['Public']
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
assert.strictEqual(isPublic(activity), true)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('returns false for non-public', () => {
|
|
122
|
+
const activity = {
|
|
123
|
+
type: 'Create',
|
|
124
|
+
to: ['https://example.com/users/bob']
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
assert.strictEqual(isPublic(activity), false)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('handles missing to/cc fields', () => {
|
|
131
|
+
const activity = { type: 'Create' }
|
|
132
|
+
|
|
133
|
+
assert.strictEqual(isPublic(activity), false)
|
|
134
|
+
})
|
|
135
|
+
})
|