Skip to content

Commit 1fc67a5

Browse files
authored
feat: increase test coverage in ws-server (#4333)
Signed-off-by: nikolay <[email protected]>
1 parent a7d183a commit 1fc67a5

File tree

8 files changed

+786
-11
lines changed

8 files changed

+786
-11
lines changed

packages/ws-server/src/utils/validators.ts

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -67,13 +67,3 @@ export const validateSubscribeEthLogsParams = async (
6767
}
6868
}
6969
};
70-
71-
/**
72-
* Validates whether a given string is a 32-byte hexadecimal string.
73-
* Checks if the string starts with '0x' followed by 64 hexadecimal characters.
74-
* @param {string} hash - The string to be validated.
75-
* @returns {boolean} Returns true if the string is a valid 32-byte hexadecimal string, otherwise false.
76-
*/
77-
export const validate32bytesHexaString = (hash: string): boolean => {
78-
return /^0x[0-9a-fA-F]{64}$/.test(hash);
79-
};

packages/ws-server/tests/unit/connectionLimiter.spec.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,4 +364,12 @@ describe('Connection Limiter', function () {
364364
expect(ctx.websocket.subscriptions).to.eq(0);
365365
});
366366
});
367+
368+
describe('validateSubscriptionLimit', function () {
369+
it('should return true if the limit is reached', function () {
370+
const ctx = createMockContext({ subscriptions: 5 });
371+
372+
expect(connectionLimiter.validateSubscriptionLimit(ctx)).to.be.true;
373+
});
374+
});
367375
});
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
import { MirrorNodeClient } from '@hashgraph/json-rpc-relay/dist/lib/clients/mirrorNodeClient';
3+
import { predefined } from '@hashgraph/json-rpc-relay/dist/lib/errors/JsonRpcError';
4+
import { Relay } from '@hashgraph/json-rpc-relay/dist/lib/relay';
5+
import { RequestDetails } from '@hashgraph/json-rpc-relay/dist/lib/types/RequestDetails';
6+
import { IJsonRpcRequest } from '@hashgraph/json-rpc-server/dist/koaJsonRpc/lib/IJsonRpcRequest';
7+
import { expect } from 'chai';
8+
import Koa from 'koa';
9+
import { Counter } from 'prom-client';
10+
import sinon from 'sinon';
11+
12+
import { getRequestResult } from '../../../dist/controllers/jsonRpcController';
13+
import WsMetricRegistry from '../../../dist/metrics/wsMetricRegistry';
14+
import { SubscriptionService } from '../../../dist/service/subscriptionService';
15+
import ConnectionLimiter from '../../../src/metrics/connectionLimiter';
16+
import { WS_CONSTANTS } from '../../../src/utils/constants';
17+
18+
function createMockContext(): Koa.Context {
19+
return {
20+
websocket: {
21+
id: 'test-connection-id',
22+
send: sinon.stub(),
23+
close: sinon.stub(),
24+
inactivityTTL: undefined,
25+
ipCounted: false,
26+
subscriptions: 0,
27+
},
28+
request: { ip: '127.0.0.1' },
29+
app: { server: { _connections: 0 } },
30+
} as Koa.Context;
31+
}
32+
33+
describe('JSON Rpc Controller', function () {
34+
let mockLogger: any;
35+
let stubWsMetricRegistry: WsMetricRegistry;
36+
let stubRelay: Relay;
37+
let stubConnectionLimiter: ConnectionLimiter;
38+
let stubMirrorNodeClient: MirrorNodeClient;
39+
let stubSubscriptionService: SubscriptionService;
40+
let requestDetails: RequestDetails;
41+
42+
beforeEach(() => {
43+
mockLogger = {
44+
warn: sinon.stub(),
45+
trace: sinon.stub(),
46+
isLevelEnabled: sinon.stub().returns(true),
47+
};
48+
stubWsMetricRegistry = sinon.createStubInstance(WsMetricRegistry);
49+
stubWsMetricRegistry.getCounter.returns({
50+
labels: () => {
51+
return { inc: sinon.stub() };
52+
},
53+
} as unknown as Counter);
54+
stubRelay = sinon.createStubInstance(Relay);
55+
stubConnectionLimiter = sinon.createStubInstance(ConnectionLimiter);
56+
stubMirrorNodeClient = sinon.createStubInstance(MirrorNodeClient);
57+
stubSubscriptionService = sinon.createStubInstance(SubscriptionService);
58+
requestDetails = new RequestDetails({
59+
requestId: '3',
60+
ipAddress: '0.0.0.0',
61+
connectionId: '9',
62+
});
63+
});
64+
65+
afterEach(() => {
66+
sinon.restore();
67+
});
68+
69+
describe('getRequestResult', async function () {
70+
let defaultRequestParams: any;
71+
72+
beforeEach(() => {
73+
defaultRequestParams = [
74+
createMockContext(),
75+
stubRelay,
76+
mockLogger,
77+
{ id: '2', method: WS_CONSTANTS.METHODS.ETH_CHAINID, jsonrpc: '2.0' } as IJsonRpcRequest,
78+
stubConnectionLimiter,
79+
stubMirrorNodeClient,
80+
stubWsMetricRegistry,
81+
requestDetails,
82+
stubSubscriptionService,
83+
];
84+
});
85+
86+
it('should throw invalid request if id is missing from request body', async function () {
87+
defaultRequestParams[3] = { method: WS_CONSTANTS.METHODS.ETH_CHAINID, jsonrpc: '2.0' } as IJsonRpcRequest;
88+
const resp = await getRequestResult(...defaultRequestParams);
89+
90+
expect(resp.error.code).to.equal(-32600);
91+
expect(resp.error.message).to.include('Invalid Request');
92+
});
93+
94+
it('should throw method not found if passed method is not existing', async function () {
95+
const nonExistingMethod = 'eth_non-existing-method';
96+
defaultRequestParams[3] = { id: '2', method: nonExistingMethod, jsonrpc: '2.0' } as IJsonRpcRequest;
97+
const resp = await getRequestResult(...defaultRequestParams);
98+
99+
expect(resp.error.code).to.equal(-32601);
100+
expect(resp.error.message).to.include(`Method ${nonExistingMethod} not found`);
101+
});
102+
103+
it('should throw IP Rate Limit exceeded error if .shouldRateLimitOnMethod returns true', async function () {
104+
stubConnectionLimiter.shouldRateLimitOnMethod.returns(true);
105+
const resp = await getRequestResult(...defaultRequestParams);
106+
107+
expect(resp.error.code).to.equal(-32605);
108+
expect(resp.error.message).to.include('IP Rate limit exceeded');
109+
});
110+
111+
it('should throw Max Subscription error if subscription limit is reached', async function () {
112+
stubConnectionLimiter.validateSubscriptionLimit.returns(false);
113+
defaultRequestParams[3] = {
114+
id: '2',
115+
method: WS_CONSTANTS.METHODS.ETH_SUBSCRIBE,
116+
jsonrpc: '2.0',
117+
} as IJsonRpcRequest;
118+
const resp = await getRequestResult(...defaultRequestParams);
119+
120+
expect(resp.error.code).to.equal(-32608);
121+
expect(resp.error.message).to.include('Exceeded maximum allowed subscriptions');
122+
});
123+
124+
it('should throw error on eth_subscribe if WS Subscriptions are disabled', async function () {
125+
stubConnectionLimiter.validateSubscriptionLimit.returns(true);
126+
defaultRequestParams[3] = {
127+
id: '2',
128+
method: WS_CONSTANTS.METHODS.ETH_SUBSCRIBE,
129+
jsonrpc: '2.0',
130+
} as IJsonRpcRequest;
131+
const resp = await getRequestResult(...defaultRequestParams);
132+
133+
expect(resp.error.code).to.equal(-32207);
134+
expect(resp.error.message).to.include('WS Subscriptions are disabled');
135+
});
136+
137+
it('should throw error on eth_unsubscribe if WS Subscriptions are disabled', async function () {
138+
stubConnectionLimiter.validateSubscriptionLimit.returns(true);
139+
defaultRequestParams[3] = {
140+
id: '2',
141+
method: WS_CONSTANTS.METHODS.ETH_UNSUBSCRIBE,
142+
jsonrpc: '2.0',
143+
} as IJsonRpcRequest;
144+
const resp = await getRequestResult(...defaultRequestParams);
145+
146+
expect(resp.error.code).to.equal(-32207);
147+
expect(resp.error.message).to.include('WS Subscriptions are disabled');
148+
});
149+
150+
it('should be able to execute `eth_chainId` and get a proper response', async function () {
151+
const chainId = '0x12a';
152+
stubRelay.executeRpcMethod.returns(chainId);
153+
const resp = await getRequestResult(...defaultRequestParams);
154+
155+
expect(resp.result).to.equal(chainId);
156+
});
157+
158+
it('should be able to handle the error as JsonRpcError if an internal error is thrown within the relay execution', async function () {
159+
stubRelay.executeRpcMethod.throws(predefined.INTERNAL_ERROR);
160+
const resp = await getRequestResult(...defaultRequestParams);
161+
162+
expect(resp.error.code).to.equal(-32603);
163+
expect(resp.error.message).to.include('Unknown error invoking RPC');
164+
});
165+
166+
it('should transform every error to JsonRpcError`', async function () {
167+
delete mockLogger.isLevelEnabled;
168+
169+
stubRelay.executeRpcMethod.throws(new Error('custom error'));
170+
const resp = await getRequestResult(...defaultRequestParams);
171+
172+
expect(resp.error.code).to.equal(-32603);
173+
});
174+
});
175+
});
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
import { ConfigService } from '@hashgraph/json-rpc-config-service/dist/services';
3+
import { MirrorNodeClient } from '@hashgraph/json-rpc-relay/dist/lib/clients/mirrorNodeClient';
4+
import constants from '@hashgraph/json-rpc-relay/dist/lib/constants';
5+
import { Relay } from '@hashgraph/json-rpc-relay/dist/lib/relay';
6+
import { RequestDetails } from '@hashgraph/json-rpc-relay/dist/lib/types/RequestDetails';
7+
import { IJsonRpcRequest } from '@hashgraph/json-rpc-server/dist/koaJsonRpc/lib/IJsonRpcRequest';
8+
import chai, { expect } from 'chai';
9+
import chaiAsPromised from 'chai-as-promised';
10+
import Koa from 'koa';
11+
import { Counter } from 'prom-client';
12+
import sinon from 'sinon';
13+
14+
import { contractAddress1, contractAddress2 } from '../../../../relay/tests/helpers';
15+
import { handleEthSubscribe } from '../../../dist/controllers/subscribeController';
16+
import WsMetricRegistry from '../../../dist/metrics/wsMetricRegistry';
17+
import { SubscriptionService } from '../../../dist/service/subscriptionService';
18+
import ConnectionLimiter from '../../../src/metrics/connectionLimiter';
19+
import { WS_CONSTANTS } from '../../../src/utils/constants';
20+
chai.use(chaiAsPromised);
21+
22+
function createMockContext(): Koa.Context {
23+
return {
24+
websocket: {
25+
id: 'test-connection-id',
26+
send: sinon.stub(),
27+
close: sinon.stub(),
28+
inactivityTTL: undefined,
29+
ipCounted: false,
30+
subscriptions: 0,
31+
},
32+
request: { ip: '127.0.0.1' },
33+
app: { server: { _connections: 0 } },
34+
} as Koa.Context;
35+
}
36+
37+
describe('Subscribe Controller', function () {
38+
const nonExistingMethod = 'non-existing-method';
39+
const subscriptionId = '5644';
40+
41+
let mockLogger: any;
42+
let stubWsMetricRegistry: WsMetricRegistry;
43+
let stubRelay: Relay;
44+
let stubConnectionLimiter: ConnectionLimiter;
45+
let stubMirrorNodeClient: MirrorNodeClient;
46+
let stubSubscriptionService: SubscriptionService;
47+
let stubConfigService: ConfigService;
48+
let requestDetails: RequestDetails;
49+
50+
beforeEach(() => {
51+
mockLogger = {
52+
warn: sinon.stub(),
53+
info: sinon.stub(),
54+
};
55+
stubWsMetricRegistry = sinon.createStubInstance(WsMetricRegistry);
56+
stubWsMetricRegistry.getCounter.returns({
57+
labels: () => {
58+
return { inc: sinon.stub() };
59+
},
60+
} as unknown as Counter);
61+
stubRelay = sinon.createStubInstance(Relay);
62+
stubConnectionLimiter = sinon.createStubInstance(ConnectionLimiter);
63+
stubMirrorNodeClient = sinon.createStubInstance(MirrorNodeClient);
64+
stubSubscriptionService = sinon.createStubInstance(SubscriptionService);
65+
stubConfigService = sinon.stub(ConfigService, 'get');
66+
stubConfigService.withArgs('SUBSCRIPTIONS_ENABLED').returns(true);
67+
requestDetails = new RequestDetails({
68+
requestId: '3',
69+
ipAddress: '0.0.0.0',
70+
connectionId: '9',
71+
});
72+
});
73+
74+
afterEach(() => {
75+
sinon.restore();
76+
});
77+
78+
describe('handleEthSubscribe', async function () {
79+
let defaultParams: any;
80+
81+
beforeEach(() => {
82+
defaultParams = {
83+
request: { id: '2', method: WS_CONSTANTS.METHODS.ETH_SUBSCRIBE, jsonrpc: '2.0' } as IJsonRpcRequest,
84+
method: WS_CONSTANTS.METHODS.ETH_SUBSCRIBE,
85+
params: [constants.SUBSCRIBE_EVENTS.NEW_HEADS, {}],
86+
relay: stubRelay,
87+
logger: mockLogger,
88+
limiter: stubConnectionLimiter,
89+
mirrorNodeClient: stubMirrorNodeClient,
90+
ctx: createMockContext(),
91+
requestDetails: requestDetails,
92+
subscriptionService: stubSubscriptionService,
93+
};
94+
});
95+
96+
it('should not be able to subscribe if SUBSCRIPTIONS_ENABLED is disabled', async function () {
97+
stubConfigService.withArgs('SUBSCRIPTIONS_ENABLED').returns(false);
98+
const resp = await handleEthSubscribe(defaultParams);
99+
100+
expect(resp.error.code).to.equal(-32207);
101+
expect(resp.error.message).to.contain('WS Subscriptions are disabled');
102+
});
103+
104+
it('should be able to subscribe for logs ', async function () {
105+
stubSubscriptionService.subscribe.returns(subscriptionId);
106+
const resp = await handleEthSubscribe({
107+
...defaultParams,
108+
params: [constants.SUBSCRIBE_EVENTS.LOGS, {}],
109+
});
110+
111+
expect(resp.result).to.equal(subscriptionId);
112+
});
113+
114+
it('should not be able to subscribe for logs when multiple addresses are provided as filter ', async function () {
115+
stubMirrorNodeClient.resolveEntityType.returns(true);
116+
stubSubscriptionService.subscribe.returns(subscriptionId);
117+
118+
await expect(
119+
handleEthSubscribe({
120+
...defaultParams,
121+
params: [constants.SUBSCRIBE_EVENTS.LOGS, { address: [contractAddress1, contractAddress2] }],
122+
}),
123+
).to.be.eventually.rejected.and.have.property('code', -32602);
124+
});
125+
126+
it('should not be able to subscribe to new heads if WS_NEW_HEADS_ENABLED is disabled', async function () {
127+
stubConfigService.withArgs('WS_NEW_HEADS_ENABLED').returns(false);
128+
129+
await expect(handleEthSubscribe(defaultParams)).to.be.eventually.rejected.and.have.property('code', -32601);
130+
});
131+
132+
it('should be able to subscribe to new heads', async function () {
133+
stubSubscriptionService.subscribe.returns(subscriptionId);
134+
stubConfigService.withArgs('WS_NEW_HEADS_ENABLED').returns(true);
135+
const resp = await handleEthSubscribe(defaultParams);
136+
137+
expect(resp.result).to.equal(subscriptionId);
138+
});
139+
140+
it('should throw unsupported method for non-existing method', async function () {
141+
await expect(
142+
handleEthSubscribe({
143+
...defaultParams,
144+
params: [nonExistingMethod, {}],
145+
}),
146+
).to.be.eventually.rejected.and.have.property('code', -32601);
147+
});
148+
});
149+
});

0 commit comments

Comments
 (0)