Skip to content

Commit 0f6ec74

Browse files
Marcos Spessatto Defendidebdutdeb
authored andcommitted
chore: type improvements and tests for federation sdk package (#26)
1 parent 622b9ea commit 0f6ec74

File tree

7 files changed

+571
-44
lines changed

7 files changed

+571
-44
lines changed
Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
import { describe, it, beforeEach, afterEach, expect, spyOn, mock } from 'bun:test';
2+
import { FederationRequestService } from './federation-request.service';
3+
import { FederationConfigService } from './federation-config.service';
4+
import * as nacl from 'tweetnacl';
5+
import * as discovery from '@hs/homeserver/src/helpers/server-discovery/discovery';
6+
import * as authentication from '@hs/homeserver/src/authentication';
7+
import * as signJson from '@hs/homeserver/src/signJson';
8+
import * as url from '@hs/homeserver/src/helpers/url';
9+
10+
describe('FederationRequestService', () => {
11+
let service: FederationRequestService;
12+
let configService: FederationConfigService;
13+
let originalFetch: typeof globalThis.fetch;
14+
15+
const mockServerName = 'example.com';
16+
const mockSigningKey = 'aGVsbG93b3JsZA==';
17+
const mockSigningKeyId = 'ed25519:1';
18+
19+
const mockKeyPair = {
20+
publicKey: new Uint8Array([1, 2, 3]),
21+
secretKey: new Uint8Array([4, 5, 6]),
22+
};
23+
24+
const mockDiscoveryResult = {
25+
address: 'target.example.com',
26+
headers: {
27+
'Host': 'target.example.com',
28+
'X-Custom-Header': 'Test'
29+
},
30+
};
31+
32+
const mockSignature = new Uint8Array([7, 8, 9]);
33+
34+
const mockSignedJson = {
35+
content: 'test',
36+
signatures: {
37+
'example.com': {
38+
'ed25519:1': 'abcdef',
39+
},
40+
},
41+
};
42+
43+
const mockAuthHeaders = 'X-Matrix origin="example.com",destination="target.example.com",key="ed25519:1",sig="xyz123"';
44+
45+
beforeEach(() => {
46+
originalFetch = globalThis.fetch;
47+
48+
spyOn(nacl.sign.keyPair, 'fromSecretKey').mockReturnValue(mockKeyPair);
49+
spyOn(nacl.sign, 'detached').mockReturnValue(mockSignature);
50+
51+
spyOn(discovery, 'resolveHostAddressByServerName').mockResolvedValue(mockDiscoveryResult);
52+
spyOn(url, 'extractURIfromURL').mockReturnValue('/test/path?query=value');
53+
spyOn(authentication, 'authorizationHeaders').mockResolvedValue(mockAuthHeaders);
54+
spyOn(signJson, 'signJson').mockResolvedValue(mockSignedJson);
55+
spyOn(authentication, 'computeAndMergeHash').mockImplementation((obj: any) => obj);
56+
57+
globalThis.fetch = Object.assign(
58+
async (_url: string, _options?: RequestInit) => {
59+
return {
60+
ok: true,
61+
status: 200,
62+
json: async () => ({ result: 'success' }),
63+
text: async () => '{"result":"success"}',
64+
} as Response;
65+
},
66+
{ preconnect: () => { } }
67+
) as typeof fetch;
68+
69+
configService = {
70+
serverName: mockServerName,
71+
signingKey: mockSigningKey,
72+
signingKeyId: mockSigningKeyId,
73+
} as FederationConfigService;
74+
75+
service = new FederationRequestService(configService);
76+
});
77+
78+
afterEach(() => {
79+
globalThis.fetch = originalFetch;
80+
mock.restore();
81+
});
82+
83+
describe('makeSignedRequest', () => {
84+
it('should make a successful signed request without body', async () => {
85+
const fetchSpy = spyOn(globalThis, 'fetch');
86+
87+
const result = await service.makeSignedRequest({
88+
method: 'GET',
89+
domain: 'target.example.com',
90+
uri: '/test/path',
91+
});
92+
93+
expect(configService.serverName).toBe(mockServerName);
94+
expect(configService.signingKey).toBe(mockSigningKey);
95+
expect(configService.signingKeyId).toBe(mockSigningKeyId);
96+
97+
expect(nacl.sign.keyPair.fromSecretKey).toHaveBeenCalled();
98+
99+
expect(discovery.resolveHostAddressByServerName).toHaveBeenCalledWith(
100+
'target.example.com',
101+
mockServerName
102+
);
103+
104+
expect(fetchSpy).toHaveBeenCalledWith(
105+
'https://target.example.com/test/path',
106+
expect.objectContaining({
107+
method: 'GET',
108+
headers: expect.objectContaining({
109+
Authorization: mockAuthHeaders,
110+
'X-Custom-Header': 'Test',
111+
}),
112+
})
113+
);
114+
115+
expect(result).toEqual({ result: 'success' });
116+
});
117+
118+
it('should make a successful signed request with body', async () => {
119+
const fetchSpy = spyOn(globalThis, 'fetch');
120+
121+
const mockBody = { key: 'value' };
122+
123+
const result = await service.makeSignedRequest({
124+
method: 'POST',
125+
domain: 'target.example.com',
126+
uri: '/test/path',
127+
body: mockBody,
128+
});
129+
130+
expect(signJson.signJson).toHaveBeenCalledWith(
131+
expect.objectContaining({ key: 'value', signatures: {} }),
132+
expect.any(Object),
133+
mockServerName
134+
);
135+
136+
expect(authentication.authorizationHeaders).toHaveBeenCalledWith(
137+
mockServerName,
138+
expect.any(Object),
139+
'target.example.com',
140+
'POST',
141+
'/test/path?query=value',
142+
mockSignedJson
143+
);
144+
145+
expect(fetchSpy).toHaveBeenCalledWith(
146+
'https://target.example.com/test/path',
147+
expect.objectContaining({
148+
method: 'POST',
149+
body: JSON.stringify(mockSignedJson),
150+
})
151+
);
152+
153+
expect(result).toEqual({ result: 'success' });
154+
});
155+
156+
it('should make a signed request with query parameters', async () => {
157+
const fetchSpy = spyOn(globalThis, 'fetch');
158+
159+
const result = await service.makeSignedRequest({
160+
method: 'GET',
161+
domain: 'target.example.com',
162+
uri: '/test/path',
163+
queryString: 'param1=value1&param2=value2',
164+
});
165+
166+
expect(fetchSpy).toHaveBeenCalledWith(
167+
'https://target.example.com/test/path?param1=value1&param2=value2',
168+
expect.any(Object)
169+
);
170+
171+
expect(result).toEqual({ result: 'success' });
172+
});
173+
174+
it('should handle fetch errors properly', async () => {
175+
globalThis.fetch = Object.assign(
176+
async () => {
177+
return {
178+
ok: false,
179+
status: 404,
180+
text: async () => 'Not Found',
181+
} as Response;
182+
},
183+
{ preconnect: () => { } }
184+
) as typeof fetch;
185+
186+
try {
187+
await service.makeSignedRequest({
188+
method: 'GET',
189+
domain: 'target.example.com',
190+
uri: '/test/path',
191+
});
192+
} catch (error: unknown) {
193+
if (error instanceof Error) {
194+
expect(error.message).toContain('Federation request failed: 404 Not Found');
195+
} else {
196+
throw error;
197+
}
198+
}
199+
});
200+
201+
it('should handle JSON error responses properly', async () => {
202+
globalThis.fetch = Object.assign(
203+
async () => {
204+
return {
205+
ok: false,
206+
status: 400,
207+
text: async () => '{"error":"Bad Request","code":"M_INVALID_PARAM"}',
208+
} as Response;
209+
},
210+
{ preconnect: () => { } }
211+
) as typeof fetch;
212+
213+
try {
214+
await service.makeSignedRequest({
215+
method: 'GET',
216+
domain: 'target.example.com',
217+
uri: '/test/path',
218+
});
219+
} catch (error: unknown) {
220+
if (error instanceof Error) {
221+
expect(error.message).toContain('Federation request failed: 400 {"error":"Bad Request","code":"M_INVALID_PARAM"}');
222+
} else {
223+
throw error;
224+
}
225+
}
226+
});
227+
228+
it('should handle network errors properly', async () => {
229+
globalThis.fetch = Object.assign(
230+
async () => {
231+
throw new Error('Network Error');
232+
},
233+
{ preconnect: () => { } }
234+
) as typeof fetch;
235+
236+
try {
237+
await service.makeSignedRequest({
238+
method: 'GET',
239+
domain: 'target.example.com',
240+
uri: '/test/path',
241+
});
242+
} catch (error: unknown) {
243+
if (error instanceof Error) {
244+
expect(error.message).toBe('Network Error');
245+
} else {
246+
throw error;
247+
}
248+
}
249+
});
250+
});
251+
252+
describe('convenience methods', () => {
253+
it('should call makeSignedRequest with correct parameters for GET', async () => {
254+
const makeSignedRequestSpy = spyOn(service, 'makeSignedRequest').mockResolvedValue({ result: 'success' });
255+
256+
await service.get('target.example.com', '/api/resource', { filter: 'active' });
257+
258+
expect(makeSignedRequestSpy).toHaveBeenCalledWith({
259+
method: 'GET',
260+
domain: 'target.example.com',
261+
uri: '/api/resource',
262+
queryString: 'filter=active',
263+
});
264+
});
265+
266+
it('should call makeSignedRequest with correct parameters for POST', async () => {
267+
const makeSignedRequestSpy = spyOn(service, 'makeSignedRequest').mockResolvedValue({ result: 'success' });
268+
269+
const body = { data: 'example' };
270+
await service.post('target.example.com', '/api/resource', body, { version: '1' });
271+
272+
expect(makeSignedRequestSpy).toHaveBeenCalledWith({
273+
method: 'POST',
274+
domain: 'target.example.com',
275+
uri: '/api/resource',
276+
body,
277+
queryString: 'version=1',
278+
});
279+
});
280+
281+
it('should call makeSignedRequest with correct parameters for PUT', async () => {
282+
const makeSignedRequestSpy = spyOn(service, 'makeSignedRequest').mockResolvedValue({ result: 'success' });
283+
284+
const body = { data: 'updated' };
285+
await service.put('target.example.com', '/api/resource/123', body);
286+
287+
expect(makeSignedRequestSpy).toHaveBeenCalledWith({
288+
method: 'PUT',
289+
domain: 'target.example.com',
290+
uri: '/api/resource/123',
291+
body,
292+
queryString: '',
293+
});
294+
});
295+
});
296+
});

packages/federation-sdk/src/services/federation-request.service.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,15 @@ import { Injectable, Logger } from '@nestjs/common';
33
import * as nacl from 'tweetnacl';
44
import { authorizationHeaders, computeAndMergeHash } from '../../../homeserver/src/authentication';
55
import { extractURIfromURL } from '../../../homeserver/src/helpers/url';
6-
import { signJson } from '../../../homeserver/src/signJson';
6+
import { EncryptionValidAlgorithm, signJson } from '../../../homeserver/src/signJson';
77
import { getHomeserverFinalAddress } from '../server-discovery/discovery';
88
import { FederationConfigService } from './federation-config.service';
99

1010
interface SignedRequest {
1111
method: string;
1212
domain: string;
1313
uri: string;
14-
body?: any;
14+
body?: Record<string, unknown>;
1515
queryString?: string;
1616
}
1717

@@ -37,8 +37,8 @@ export class FederationRequestService {
3737
const privateKeyBytes = Buffer.from(signingKeyBase64, 'base64');
3838
const keyPair = nacl.sign.keyPair.fromSecretKey(privateKeyBytes);
3939

40-
const signingKey = {
41-
algorithm: 'ed25519',
40+
const signingKey: SigningKey = {
41+
algorithm: EncryptionValidAlgorithm.ed25519,
4242
version: signingKeyId.split(':')[1] || '1',
4343
privateKey: keyPair.secretKey,
4444
publicKey: keyPair.publicKey,
@@ -54,27 +54,27 @@ export class FederationRequestService {
5454

5555
this.logger.debug(`Making ${method} request to ${url.toString()}`);
5656

57-
let signedBody: unknown;
57+
let signedBody: Record<string, unknown> | undefined;
5858
if (body) {
5959
signedBody = await signJson(
6060
computeAndMergeHash({ ...body, signatures: {} }),
61-
signingKey as any,
61+
signingKey,
6262
serverName
6363
);
6464
}
6565

6666
const auth = await authorizationHeaders(
6767
serverName,
68-
signingKey as unknown as SigningKey,
68+
signingKey,
6969
domain,
7070
method,
7171
extractURIfromURL(url),
72-
signedBody as any,
72+
signedBody,
7373
);
7474

7575
const response = await fetch(url.toString(), {
7676
method,
77-
...(signedBody && { body: JSON.stringify(signedBody) }) as any,
77+
...(signedBody && { body: JSON.stringify(signedBody) }),
7878
headers: {
7979
Authorization: auth,
8080
...discoveryHeaders,
@@ -90,14 +90,14 @@ export class FederationRequestService {
9090
throw new Error(`Federation request failed: ${response.status} ${errorDetail}`);
9191
}
9292

93-
return response.json() as Promise<T>;
93+
return response.json();
9494
} catch (error: any) {
9595
this.logger.error(`Federation request failed: ${error.message}`, error.stack);
9696
throw error;
9797
}
9898
}
9999

100-
async request<T>(method: HttpMethod, targetServer: string, endpoint: string, body?: any, queryParams?: Record<string, string>): Promise<T> {
100+
async request<T>(method: HttpMethod, targetServer: string, endpoint: string, body?: Record<string, unknown>, queryParams?: Record<string, string>): Promise<T> {
101101
let queryString = '';
102102

103103
if (queryParams) {

0 commit comments

Comments
 (0)