Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 13 additions & 1 deletion src/shadowbox/server/api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -506,9 +506,19 @@ paths:
description: Access key inexistent
/metrics/transfer:
get:
description: Returns the data transferred per access key
description: Returns the data transferred per access key for the specified time duration
tags:
- Access Key
parameters:
- name: last
in: query
required: false
description: Time duration to retrieve data usage for (default is 30d = 30 days). Format is a number followed by a unit - h (hours), d (days), or w (weeks).
schema:
type: string
pattern: '^\d+[hdw]$'
default: '30d'
example: '2h'
responses:
'200':
description: The data transferred by each access key
Expand All @@ -524,6 +534,8 @@ paths:
examples:
'0':
value: '{"bytesTransferredByUserId":{"1":1008040941,"2":5958113497,"3":752221577}}'
'400':
description: Invalid hours parameter
/metrics/enabled:
get:
description: Returns whether metrics is being shared
Expand Down
178 changes: 178 additions & 0 deletions src/shadowbox/server/manager_service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1037,6 +1037,179 @@ describe('ShadowsocksManagerService', () => {
});
});

describe('getDataUsage', () => {
it('Uses default 30-day timeframe when no last parameter provided', async (done) => {
const mockMetrics = jasmine.createSpyObj<ManagerMetrics>('ManagerMetrics', [
'getOutboundByteTransfer',
]);
const expectedResponse = {bytesTransferredByUserId: {key1: 1000, key2: 2000}};
mockMetrics.getOutboundByteTransfer.and.returnValue(Promise.resolve(expectedResponse));

const service = new ShadowsocksManagerServiceBuilder().managerMetrics(mockMetrics).build();

service.getDataUsage(
{params: {}, query: {}},
{
send: (httpCode, data) => {
expect(httpCode).toEqual(200);
expect(data).toEqual(expectedResponse);
// Expect default 30 days = 30 * 24 = 720 hours
expect(mockMetrics.getOutboundByteTransfer).toHaveBeenCalledWith({hours: 720});
responseProcessed = true;
},
},
done
);
});

it('Uses provided last parameter correctly', async (done) => {
const mockMetrics = jasmine.createSpyObj<ManagerMetrics>('ManagerMetrics', [
'getOutboundByteTransfer',
]);
const expectedResponse = {bytesTransferredByUserId: {key1: 1500}};
mockMetrics.getOutboundByteTransfer.and.returnValue(Promise.resolve(expectedResponse));

const service = new ShadowsocksManagerServiceBuilder().managerMetrics(mockMetrics).build();

service.getDataUsage(
{params: {}, query: {last: '2d'}}, // 2 days = 48 hours
{
send: (httpCode, data) => {
expect(httpCode).toEqual(200);
expect(data).toEqual(expectedResponse);
expect(mockMetrics.getOutboundByteTransfer).toHaveBeenCalledWith({hours: 48});
responseProcessed = true;
},
},
done
);
});

it('Handles hours format correctly', async (done) => {
const mockMetrics = jasmine.createSpyObj<ManagerMetrics>('ManagerMetrics', [
'getOutboundByteTransfer',
]);
const expectedResponse = {bytesTransferredByUserId: {key1: 3000}};
mockMetrics.getOutboundByteTransfer.and.returnValue(Promise.resolve(expectedResponse));

const service = new ShadowsocksManagerServiceBuilder().managerMetrics(mockMetrics).build();

service.getDataUsage(
{params: {}, query: {last: '24h'}}, // 24 hours
{
send: (httpCode, data) => {
expect(httpCode).toEqual(200);
expect(data).toEqual(expectedResponse);
expect(mockMetrics.getOutboundByteTransfer).toHaveBeenCalledWith({hours: 24});
responseProcessed = true;
},
},
done
);
});

it('Rejects negative last parameter', async (done) => {
const service = new ShadowsocksManagerServiceBuilder().build();
const res = {send: SEND_NOTHING};

service.getDataUsage({params: {}, query: {last: '-12h'}}, res, (error) => {
expect(error.statusCode).toEqual(400);
expect(error.message).toContain('Invalid time range');
responseProcessed = true;
done();
});
});

it('Rejects zero last parameter', async (done) => {
const service = new ShadowsocksManagerServiceBuilder().build();
const res = {send: SEND_NOTHING};

service.getDataUsage({params: {}, query: {last: '0h'}}, res, (error) => {
expect(error.statusCode).toEqual(400);
expect(error.message).toContain('Invalid time range');
responseProcessed = true;
done();
});
});

it('Rejects invalid string last parameter', async (done) => {
const service = new ShadowsocksManagerServiceBuilder().build();
const res = {send: SEND_NOTHING};

service.getDataUsage({params: {}, query: {last: 'invalid'}}, res, (error) => {
expect(error.statusCode).toEqual(400);
expect(error.message).toContain('Invalid time range');
responseProcessed = true;
done();
});
});

it('Rejects non-string last parameter', async (done) => {
const service = new ShadowsocksManagerServiceBuilder().build();
const res = {send: SEND_NOTHING};

service.getDataUsage({params: {}, query: {last: {invalid: 'object'}}}, res, (error) => {
expect(error.statusCode).toEqual(400);
expect(error.message).toContain('Parameter `last` must be a string');
responseProcessed = true;
done();
});
});

it('Handles weeks format correctly', async (done) => {
const mockMetrics = jasmine.createSpyObj<ManagerMetrics>('ManagerMetrics', [
'getOutboundByteTransfer',
]);
const expectedResponse = {bytesTransferredByUserId: {key1: 5000}};
mockMetrics.getOutboundByteTransfer.and.returnValue(Promise.resolve(expectedResponse));

const service = new ShadowsocksManagerServiceBuilder().managerMetrics(mockMetrics).build();

service.getDataUsage(
{params: {}, query: {last: '2w'}}, // 2 weeks = 14 days = 336 hours
{
send: (httpCode, data) => {
expect(httpCode).toEqual(200);
expect(data).toEqual(expectedResponse);
expect(mockMetrics.getOutboundByteTransfer).toHaveBeenCalledWith({hours: 336});
responseProcessed = true;
},
},
done
);
});

it('Rejects invalid time unit', async (done) => {
const service = new ShadowsocksManagerServiceBuilder().build();
const res = {send: SEND_NOTHING};

service.getDataUsage({params: {}, query: {last: '30s'}}, res, (error) => {
expect(error.statusCode).toEqual(400);
expect(error.message).toContain('Invalid time range');
responseProcessed = true;
done();
});
});

it('Handles manager metrics error properly', async (done) => {
const mockMetrics = jasmine.createSpyObj<ManagerMetrics>('ManagerMetrics', [
'getOutboundByteTransfer',
]);
mockMetrics.getOutboundByteTransfer.and.returnValue(
Promise.reject(new Error('Prometheus error'))
);

const service = new ShadowsocksManagerServiceBuilder().managerMetrics(mockMetrics).build();
const res = {send: SEND_NOTHING};

service.getDataUsage({params: {}, query: {last: '24h'}}, res, (error) => {
expect(error.statusCode).toEqual(500);
responseProcessed = true;
done();
});
});
});

describe('getShareMetrics', () => {
it('Returns value from sharedMetrics', (done) => {
const sharedMetrics = fakeSharedMetricsReporter();
Expand Down Expand Up @@ -1212,6 +1385,11 @@ describe('convertTimeRangeToHours', () => {
expect(() => convertTimeRangeToSeconds('hi mom')).toThrow();
expect(() => convertTimeRangeToSeconds('1j')).toThrow();
});

it('throws when zero or negative values are provided', () => {
expect(() => convertTimeRangeToSeconds('0h')).toThrow();
expect(() => convertTimeRangeToSeconds('-5d')).toThrow();
});
});

class ShadowsocksManagerServiceBuilder {
Expand Down
41 changes: 37 additions & 4 deletions src/shadowbox/server/manager_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,8 +201,8 @@ export function convertTimeRangeToSeconds(timeRange: string): number {
const timeRangeValue = Number(timeRange.slice(0, -1));
const timeRangeUnit = timeRange.slice(-1);

if (isNaN(timeRangeValue) || !TIME_RANGE_UNIT_TO_SECONDS_MULTIPLYER[timeRangeUnit]) {
throw new TypeError(`Invalid time range: ${timeRange}`);
if (isNaN(timeRangeValue) || timeRangeValue <= 0 || !TIME_RANGE_UNIT_TO_SECONDS_MULTIPLYER[timeRangeUnit]) {
throw new TypeError(`Invalid time range: ${timeRange}. Must be a positive number followed by 's', 'h', 'd', or 'w'.`);
}

return timeRangeValue * TIME_RANGE_UNIT_TO_SECONDS_MULTIPLYER[timeRangeUnit];
Expand Down Expand Up @@ -616,8 +616,41 @@ export class ShadowsocksManagerService {

async getDataUsage(req: RequestType, res: ResponseType, next: restify.Next) {
try {
logging.debug(`getDataUsage request ${JSON.stringify(req.params)}`);
const response = await this.managerMetrics.getOutboundByteTransfer({hours: 30 * 24});
logging.debug(
`getDataUsage request ${JSON.stringify(req.params)} ${JSON.stringify(req.query)}`
);

// Default to 30 days for backward compatibility
const DEFAULT_TIME_RANGE = '30d';

let timeRange = DEFAULT_TIME_RANGE;
if (req.query?.last !== undefined) {
const lastParam = req.query.last;
if (typeof lastParam !== 'string') {
return next(
new restifyErrors.InvalidArgumentError(
{statusCode: 400},
'Parameter `last` must be a string in format like "2h", "30d", or "1w"'
)
);
}
timeRange = lastParam;
}

let seconds: number;
try {
seconds = convertTimeRangeToSeconds(timeRange);
} catch (error) {
return next(
new restifyErrors.InvalidArgumentError(
{statusCode: 400},
`Invalid time range: ${timeRange}. Must be a positive number followed by 'h', 'd', or 'w'.`
)
);
}

const hours = seconds / 3600;
const response = await this.managerMetrics.getOutboundByteTransfer({hours});
res.send(HttpSuccess.OK, response);
logging.debug(`getDataUsage response ${JSON.stringify(response)}`);
return next();
Expand Down