outline-server/src/shadowbox/server/manager_service.spec.ts
Vinicius Fortuna 3034e59f9f Fix lint
2022-03-09 23:10:15 +00:00

988 lines
34 KiB
TypeScript

// Copyright 2018 The Outline Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import fetch from 'node-fetch';
import * as net from 'net';
import * as restify from 'restify';
import {InMemoryConfig, JsonConfig} from '../infrastructure/json_config';
import {AccessKey, AccessKeyRepository, DataLimit} from '../model/access_key';
import {ManagerMetrics} from './manager_metrics';
import {bindService, ShadowsocksManagerService} from './manager_service';
import {FakePrometheusClient, FakeShadowsocksServer} from './mocks/mocks';
import {AccessKeyConfigJson, ServerAccessKeyRepository} from './server_access_key';
import {ServerConfigJson} from './server_config';
import {SharedMetricsPublisher} from './shared_metrics';
interface ServerInfo {
name: string;
accessKeyDataLimit?: DataLimit;
}
const NEW_PORT = 12345;
const OLD_PORT = 54321;
const EXPECTED_ACCESS_KEY_PROPERTIES = [
'id',
'name',
'password',
'port',
'method',
'accessUrl',
'dataLimit',
].sort();
describe('ShadowsocksManagerService', () => {
// After processing the response callback, we should set
// responseProcessed=true. This is so we can detect that first the response
// callback is invoked, followed by the next (done) callback.
let responseProcessed = false;
beforeEach(() => {
responseProcessed = false;
});
afterEach(() => {
expect(responseProcessed).toEqual(true);
});
describe('getServer', () => {
it('Return default name if name is absent', (done) => {
const repo = getAccessKeyRepository();
const serverConfig = new InMemoryConfig({} as ServerConfigJson);
const service = new ShadowsocksManagerServiceBuilder()
.serverConfig(serverConfig)
.accessKeys(repo)
.build();
service.getServer(
{params: {}},
{
send: (httpCode, data: ServerInfo) => {
expect(httpCode).toEqual(200);
expect(data.name).toEqual('default name');
responseProcessed = true;
},
},
done
);
});
it('Returns persisted properties', (done) => {
const repo = getAccessKeyRepository();
const defaultDataLimit = {bytes: 999};
const serverConfig = new InMemoryConfig({
name: 'Server',
accessKeyDataLimit: defaultDataLimit,
} as ServerConfigJson);
const service = new ShadowsocksManagerServiceBuilder()
.serverConfig(serverConfig)
.accessKeys(repo)
.build();
service.getServer(
{params: {}},
{
send: (httpCode, data: ServerInfo) => {
expect(httpCode).toEqual(200);
expect(data.name).toEqual('Server');
expect(data.accessKeyDataLimit).toEqual(defaultDataLimit);
responseProcessed = true;
},
},
done
);
});
});
describe('renameServer', () => {
it('Rename changes the server name', (done) => {
const repo = getAccessKeyRepository();
const serverConfig = new InMemoryConfig({} as ServerConfigJson);
const service = new ShadowsocksManagerServiceBuilder()
.serverConfig(serverConfig)
.accessKeys(repo)
.build();
service.renameServer(
{params: {name: 'new name'}},
{
send: (httpCode, _) => {
expect(httpCode).toEqual(204);
expect(serverConfig.mostRecentWrite.name).toEqual('new name');
responseProcessed = true;
},
},
done
);
});
});
describe('setHostnameForAccessKeys', () => {
it(`accepts valid hostnames`, (done) => {
const serverConfig = new InMemoryConfig({} as ServerConfigJson);
const service = new ShadowsocksManagerServiceBuilder()
.serverConfig(serverConfig)
.accessKeys(getAccessKeyRepository())
.build();
const res = {
send: (httpCode) => {
expect(httpCode).toEqual(204);
},
};
const goodHostnames = [
'-bad',
'localhost',
'example.com',
'www.example.org',
'www.exa-mple.tw',
'123abc.co.uk',
'93.184.216.34',
'::0',
'2606:2800:220:1:248:1893:25c8:1946',
];
for (const hostname of goodHostnames) {
service.setHostnameForAccessKeys({params: {hostname}}, res, () => {});
}
responseProcessed = true;
done();
});
it(`rejects invalid hostnames`, (done) => {
const serverConfig = new InMemoryConfig({} as ServerConfigJson);
const service = new ShadowsocksManagerServiceBuilder()
.serverConfig(serverConfig)
.accessKeys(getAccessKeyRepository())
.build();
const res = {send: (httpCode) => {}};
const next = (error) => {
expect(error.statusCode).toEqual(400);
};
const badHostnames = [
null,
'',
'-abc.com',
'abc-.com',
'abc.com/def',
'i_have_underscores.net',
'gggg:ggg:220:1:248:1893:25c8:1946',
];
for (const hostname of badHostnames) {
service.setHostnameForAccessKeys({params: {hostname}}, res, next);
}
responseProcessed = true;
done();
});
it("Changes the server's hostname", (done) => {
const serverConfig = new InMemoryConfig({} as ServerConfigJson);
const service = new ShadowsocksManagerServiceBuilder()
.serverConfig(serverConfig)
.accessKeys(getAccessKeyRepository())
.build();
const hostname = 'www.example.org';
const res = {
send: (httpCode) => {
expect(httpCode).toEqual(204);
expect(serverConfig.data().hostname).toEqual(hostname);
responseProcessed = true;
},
};
service.setHostnameForAccessKeys({params: {hostname}}, res, done);
});
it('Rejects missing hostname', (done) => {
const serverConfig = new InMemoryConfig({} as ServerConfigJson);
const service = new ShadowsocksManagerServiceBuilder()
.serverConfig(serverConfig)
.accessKeys(getAccessKeyRepository())
.build();
const res = {send: (httpCode) => {}};
const next = (error) => {
expect(error.statusCode).toEqual(400);
responseProcessed = true;
done();
};
const missingHostname = {params: {}} as {params: {hostname: string}};
service.setHostnameForAccessKeys(missingHostname, res, next);
});
it('Rejects non-string hostname', (done) => {
const serverConfig = new InMemoryConfig({} as ServerConfigJson);
const service = new ShadowsocksManagerServiceBuilder()
.serverConfig(serverConfig)
.accessKeys(getAccessKeyRepository())
.build();
const res = {send: (httpCode) => {}};
const next = (error) => {
expect(error.statusCode).toEqual(400);
responseProcessed = true;
done();
};
// tslint:disable-next-line: no-any
const badHostname = {params: {hostname: 123}} as any as {params: {hostname: string}};
service.setHostnameForAccessKeys(badHostname, res, next);
});
});
describe('listAccessKeys', () => {
it('lists access keys in order', async (done) => {
const repo = getAccessKeyRepository();
const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build();
// Create 2 access keys with names.
const key1 = await createNewAccessKeyWithName(repo, 'keyName1');
const key2 = await createNewAccessKeyWithName(repo, 'keyName2');
// Verify that response returns keys in correct order with correct names.
const res = {
send: (httpCode, data) => {
expect(httpCode).toEqual(200);
expect(data.accessKeys.length).toEqual(2);
expect(data.accessKeys[0].name).toEqual(key1.name);
expect(data.accessKeys[0].id).toEqual(key1.id);
expect(data.accessKeys[1].name).toEqual(key2.name);
expect(data.accessKeys[1].id).toEqual(key2.id);
responseProcessed = true; // required for afterEach to pass.
},
};
service.listAccessKeys({params: {}}, res, done);
});
it('lists access keys with expected properties', async (done) => {
const repo = getAccessKeyRepository();
const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build();
const accessKey = await repo.createNewAccessKey();
await repo.createNewAccessKey();
const accessKeyName = 'new name';
await repo.renameAccessKey(accessKey.id, accessKeyName);
const res = {
send: (httpCode, data) => {
expect(httpCode).toEqual(200);
expect(data.accessKeys.length).toEqual(2);
const serviceAccessKey1 = data.accessKeys[0];
const serviceAccessKey2 = data.accessKeys[1];
expect(Object.keys(serviceAccessKey1).sort()).toEqual(EXPECTED_ACCESS_KEY_PROPERTIES);
expect(Object.keys(serviceAccessKey2).sort()).toEqual(EXPECTED_ACCESS_KEY_PROPERTIES);
expect(serviceAccessKey1.name).toEqual(accessKeyName);
responseProcessed = true; // required for afterEach to pass.
},
};
service.listAccessKeys({params: {}}, res, done);
});
});
describe('createNewAccessKey', () => {
it('creates keys', (done) => {
const repo = getAccessKeyRepository();
const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build();
// Verify that response returns a key with the expected properties.
const res = {
send: (httpCode, data) => {
expect(httpCode).toEqual(201);
expect(Object.keys(data).sort()).toEqual(EXPECTED_ACCESS_KEY_PROPERTIES);
responseProcessed = true; // required for afterEach to pass.
},
};
service.createNewAccessKey({params: {}}, res, done);
});
it('Create returns a 500 when the repository throws an exception', (done) => {
const repo = getAccessKeyRepository();
spyOn(repo, 'createNewAccessKey').and.throwError('cannot write to disk');
const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build();
const res = {send: (httpCode, data) => {}};
service.createNewAccessKey({params: {}}, res, (error) => {
expect(error.statusCode).toEqual(500);
responseProcessed = true; // required for afterEach to pass.
done();
});
});
});
describe('setPortForNewAccessKeys', () => {
it('changes ports for new access keys', async (done) => {
const repo = getAccessKeyRepository();
const serverConfig = new InMemoryConfig({} as ServerConfigJson);
const service = new ShadowsocksManagerServiceBuilder()
.serverConfig(serverConfig)
.accessKeys(repo)
.build();
const oldKey = await repo.createNewAccessKey();
const res = {
send: (httpCode) => {
expect(httpCode).toEqual(204);
},
};
await service.setPortForNewAccessKeys({params: {port: NEW_PORT}}, res, () => {});
const newKey = await repo.createNewAccessKey();
expect(newKey.proxyParams.portNumber).toEqual(NEW_PORT);
expect(oldKey.proxyParams.portNumber).not.toEqual(NEW_PORT);
responseProcessed = true;
done();
});
it('changes the server config', async (done) => {
const repo = getAccessKeyRepository();
const serverConfig = new InMemoryConfig({} as ServerConfigJson);
const service = new ShadowsocksManagerServiceBuilder()
.serverConfig(serverConfig)
.accessKeys(repo)
.build();
const res = {
send: (httpCode) => {
expect(httpCode).toEqual(204);
expect(serverConfig.data().portForNewAccessKeys).toEqual(NEW_PORT);
responseProcessed = true;
},
};
await service.setPortForNewAccessKeys({params: {port: NEW_PORT}}, res, done);
});
it('rejects invalid port numbers', async (done) => {
const repo = getAccessKeyRepository();
const serverConfig = new InMemoryConfig({} as ServerConfigJson);
const service = new ShadowsocksManagerServiceBuilder()
.serverConfig(serverConfig)
.accessKeys(repo)
.build();
const res = {
send: (httpCode) => {
fail(
`setPortForNewAccessKeys should have failed with 400 Bad Request, instead succeeded with code ${httpCode}`
);
},
};
const next = (error) => {
// Bad Request
expect(error.statusCode).toEqual(400);
};
await service.setPortForNewAccessKeys({params: {port: -1}}, res, next);
await service.setPortForNewAccessKeys({params: {port: 0}}, res, next);
await service.setPortForNewAccessKeys({params: {port: 100.1}}, res, next);
await service.setPortForNewAccessKeys({params: {port: 65536}}, res, next);
responseProcessed = true;
done();
});
it('rejects port numbers already in use', async (done) => {
const repo = getAccessKeyRepository();
const serverConfig = new InMemoryConfig({} as ServerConfigJson);
const service = new ShadowsocksManagerServiceBuilder()
.serverConfig(serverConfig)
.accessKeys(repo)
.build();
const res = {
send: (httpCode) => {
fail(
`setPortForNewAccessKeys should have failed with 409 Conflict, instead succeeded with code ${httpCode}`
);
},
};
const next = (error) => {
// Conflict
expect(error.statusCode).toEqual(409);
responseProcessed = true;
done();
};
const server = new net.Server();
server.listen(NEW_PORT, async () => {
await service.setPortForNewAccessKeys({params: {port: NEW_PORT}}, res, next);
});
});
it('accepts port numbers already in use by access keys', async (done) => {
const repo = getAccessKeyRepository();
const serverConfig = new InMemoryConfig({} as ServerConfigJson);
const service = new ShadowsocksManagerServiceBuilder()
.serverConfig(serverConfig)
.accessKeys(repo)
.build();
await service.createNewAccessKey({params: {}}, {send: () => {}}, () => {});
await service.setPortForNewAccessKeys({params: {port: NEW_PORT}}, {send: () => {}}, () => {});
const res = {
send: (httpCode) => {
expect(httpCode).toEqual(204);
responseProcessed = true;
},
};
const firstKeyConnection = new net.Server();
firstKeyConnection.listen(OLD_PORT, async () => {
await service.setPortForNewAccessKeys({params: {port: OLD_PORT}}, res, () => {});
firstKeyConnection.close();
done();
});
});
it('rejects malformed requests', async (done) => {
const repo = getAccessKeyRepository();
const serverConfig = new InMemoryConfig({} as ServerConfigJson);
const service = new ShadowsocksManagerServiceBuilder()
.serverConfig(serverConfig)
.accessKeys(repo)
.build();
const noPort = {params: {}};
const res = {
send: (httpCode) => {
fail(
`setPortForNewAccessKeys should have failed with 400 BadRequest, instead succeeded with code ${httpCode}`
);
},
};
const next = (error) => {
expect(error.statusCode).toEqual(400);
};
await service.setPortForNewAccessKeys(noPort, res, next);
const nonNumericPort = {params: {port: 'abc'}};
await service.setPortForNewAccessKeys(
// tslint:disable-next-line: no-any
nonNumericPort as any as {params: {port: number}},
res,
next
);
responseProcessed = true;
done();
});
});
describe('removeAccessKey', () => {
it('removes keys', async (done) => {
const repo = getAccessKeyRepository();
const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build();
const key1 = await repo.createNewAccessKey();
const key2 = await repo.createNewAccessKey();
const res = {
send: (httpCode, data) => {
expect(httpCode).toEqual(204);
// expect that the only remaining key is the 2nd key we created.
const keys = repo.listAccessKeys();
expect(keys.length).toEqual(1);
expect(keys[0].id === key2.id);
responseProcessed = true; // required for afterEach to pass.
},
};
// remove the 1st key.
service.removeAccessKey({params: {id: key1.id}}, res, done);
});
it('Remove returns a 500 when the repository throws an exception', async (done) => {
const repo = getAccessKeyRepository();
spyOn(repo, 'removeAccessKey').and.throwError('cannot write to disk');
const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build();
const key = await createNewAccessKeyWithName(repo, 'keyName1');
const res = {send: (httpCode, data) => {}};
service.removeAccessKey({params: {id: key.id}}, res, (error) => {
expect(error.statusCode).toEqual(500);
responseProcessed = true; // required for afterEach to pass.
done();
});
});
});
describe('renameAccessKey', () => {
it('renames keys', async (done) => {
const repo = getAccessKeyRepository();
const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build();
const OLD_NAME = 'oldName';
const NEW_NAME = 'newName';
const key = await createNewAccessKeyWithName(repo, OLD_NAME);
expect(key.name === OLD_NAME);
const res = {
send: (httpCode, data) => {
expect(httpCode).toEqual(204);
expect(key.name === NEW_NAME);
responseProcessed = true; // required for afterEach to pass.
},
};
service.renameAccessKey({params: {id: key.id, name: NEW_NAME}}, res, done);
});
it('Rename returns a 400 when the access key id is not a string', async (done) => {
const repo = getAccessKeyRepository();
const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build();
const key = await repo.createNewAccessKey();
const res = {send: (httpCode, data) => {}};
service.renameAccessKey({params: {id: 123}}, res, (error) => {
expect(error.statusCode).toEqual(400);
responseProcessed = true; // required for afterEach to pass.
done();
});
});
it('Rename returns a 500 when the repository throws an exception', async (done) => {
const repo = getAccessKeyRepository();
spyOn(repo, 'renameAccessKey').and.throwError('cannot write to disk');
const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build();
const key = await createNewAccessKeyWithName(repo, 'oldName');
const res = {send: (httpCode, data) => {}};
service.renameAccessKey({params: {id: key.id, name: 'newName'}}, res, (error) => {
expect(error.statusCode).toEqual(500);
responseProcessed = true; // required for afterEach to pass.
done();
});
});
});
describe('setAccessKeyDataLimit', () => {
it('sets access key data limit', async (done) => {
const repo = getAccessKeyRepository();
const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build();
const key = await repo.createNewAccessKey();
const limit = {bytes: 1000};
const res = {
send: (httpCode) => {
expect(httpCode).toEqual(204);
expect(key.dataLimit.bytes).toEqual(1000);
responseProcessed = true;
done();
},
};
service.setAccessKeyDataLimit({params: {id: key.id, limit}}, res, () => {});
});
it('rejects negative numbers', async (done) => {
const repo = getAccessKeyRepository();
const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build();
const keyId = (await repo.createNewAccessKey()).id;
const limit = {bytes: -1};
service.setAccessKeyDataLimit({params: {id: keyId, limit}}, {send: () => {}}, (error) => {
expect(error.statusCode).toEqual(400);
responseProcessed = true;
done();
});
});
it('rejects non-numeric limits', async (done) => {
const repo = getAccessKeyRepository();
const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build();
const keyId = (await repo.createNewAccessKey()).id;
const limit = {bytes: '1'};
service.setAccessKeyDataLimit({params: {id: keyId, limit}}, {send: () => {}}, (error) => {
expect(error.statusCode).toEqual(400);
responseProcessed = true;
done();
});
});
it('rejects an empty request', async (done) => {
const repo = getAccessKeyRepository();
const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build();
const keyId = (await repo.createNewAccessKey()).id;
const limit = {} as DataLimit;
service.setAccessKeyDataLimit({params: {id: keyId, limit}}, {send: () => {}}, (error) => {
expect(error.statusCode).toEqual(400);
responseProcessed = true;
done();
});
});
it('rejects requests for nonexistent keys', async (done) => {
const repo = getAccessKeyRepository();
const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build();
await repo.createNewAccessKey();
const limit: DataLimit = {bytes: 1000};
service.setAccessKeyDataLimit(
{params: {id: 'not an id', limit}},
{send: () => {}},
(error) => {
expect(error.statusCode).toEqual(404);
responseProcessed = true;
done();
}
);
});
});
describe('removeAccessKeyDataLimit', () => {
it('removes an access key data limit', async (done) => {
const repo = getAccessKeyRepository();
const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build();
const key = await repo.createNewAccessKey();
repo.setAccessKeyDataLimit(key.id, {bytes: 1000});
await repo.enforceAccessKeyDataLimits();
const res = {
send: (httpCode) => {
expect(httpCode).toEqual(204);
expect(key.dataLimit).toBeFalsy();
responseProcessed = true;
done();
},
};
service.removeAccessKeyDataLimit({params: {id: key.id}}, res, () => {});
});
it('returns 404 for a nonexistent key', async (done) => {
const repo = getAccessKeyRepository();
const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build();
await repo.createNewAccessKey();
service.removeAccessKeyDataLimit({params: {id: 'not an id'}}, {send: () => {}}, (error) => {
expect(error.statusCode).toEqual(404);
responseProcessed = true;
done();
});
});
});
describe('setDefaultDataLimit', () => {
it('sets default data limit', async (done) => {
const serverConfig = new InMemoryConfig({} as ServerConfigJson);
const repo = getAccessKeyRepository();
spyOn(repo, 'setDefaultDataLimit');
const service = new ShadowsocksManagerServiceBuilder()
.serverConfig(serverConfig)
.accessKeys(repo)
.build();
const limit = {bytes: 10000};
const res = {
send: (httpCode, data) => {
expect(httpCode).toEqual(204);
expect(serverConfig.data().accessKeyDataLimit).toEqual(limit);
expect(repo.setDefaultDataLimit).toHaveBeenCalledWith(limit);
service.getServer(
{params: {}},
{
send: (httpCode, data: ServerInfo) => {
expect(httpCode).toEqual(200);
expect(data.accessKeyDataLimit).toEqual(limit);
responseProcessed = true; // required for afterEach to pass.
},
},
done
);
},
};
service.setDefaultDataLimit({params: {limit}}, res, done);
});
it('returns 400 when limit is missing values', async (done) => {
const repo = getAccessKeyRepository();
const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build();
const accessKey = await repo.createNewAccessKey();
const limit = {} as DataLimit;
const res = {send: (httpCode, data) => {}};
service.setDefaultDataLimit({params: {limit}}, res, (error) => {
expect(error.statusCode).toEqual(400);
responseProcessed = true; // required for afterEach to pass.
done();
});
});
it('returns 400 when limit has negative values', async (done) => {
const repo = getAccessKeyRepository();
const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build();
const accessKey = await repo.createNewAccessKey();
const limit = {bytes: -1};
const res = {send: (httpCode, data) => {}};
service.setDefaultDataLimit({params: {limit}}, res, (error) => {
expect(error.statusCode).toEqual(400);
responseProcessed = true; // required for afterEach to pass.
done();
});
});
it('returns 500 when the repository throws an exception', async (done) => {
const repo = getAccessKeyRepository();
spyOn(repo, 'setDefaultDataLimit').and.throwError('cannot write to disk');
const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build();
await repo.createNewAccessKey();
const limit = {bytes: 10000};
const res = {send: (httpCode, data) => {}};
service.setDefaultDataLimit({params: {limit}}, res, (error) => {
expect(error.statusCode).toEqual(500);
responseProcessed = true; // required for afterEach to pass.
done();
});
});
});
describe('removeDefaultDataLimit', () => {
it('clears default data limit', async (done) => {
const limit = {bytes: 10000};
const serverConfig = new InMemoryConfig({accessKeyDataLimit: limit} as ServerConfigJson);
const repo = getAccessKeyRepository();
spyOn(repo, 'removeDefaultDataLimit').and.callThrough();
const service = new ShadowsocksManagerServiceBuilder()
.serverConfig(serverConfig)
.accessKeys(repo)
.build();
await repo.setDefaultDataLimit(limit);
const res = {
send: (httpCode, data) => {
expect(httpCode).toEqual(204);
expect(serverConfig.data().accessKeyDataLimit).toBeUndefined();
expect(repo.removeDefaultDataLimit).toHaveBeenCalled();
responseProcessed = true; // required for afterEach to pass.
},
};
service.removeDefaultDataLimit({params: {}}, res, done);
});
it('returns 500 when the repository throws an exception', async (done) => {
const repo = getAccessKeyRepository();
spyOn(repo, 'removeDefaultDataLimit').and.throwError('cannot write to disk');
const service = new ShadowsocksManagerServiceBuilder().accessKeys(repo).build();
const accessKey = await repo.createNewAccessKey();
const res = {send: (httpCode, data) => {}};
service.removeDefaultDataLimit({params: {id: accessKey.id}}, res, (error) => {
expect(error.statusCode).toEqual(500);
responseProcessed = true; // required for afterEach to pass.
done();
});
});
});
describe('getShareMetrics', () => {
it('Returns value from sharedMetrics', (done) => {
const sharedMetrics = fakeSharedMetricsReporter();
sharedMetrics.startSharing();
const service = new ShadowsocksManagerServiceBuilder()
.metricsPublisher(sharedMetrics)
.build();
service.getShareMetrics(
{params: {}},
{
send: (httpCode, data: {metricsEnabled: boolean}) => {
expect(httpCode).toEqual(200);
expect(data.metricsEnabled).toEqual(true);
responseProcessed = true;
},
},
done
);
});
});
describe('setShareMetrics', () => {
it('Sets value in the config', (done) => {
const sharedMetrics = fakeSharedMetricsReporter();
sharedMetrics.stopSharing();
const service = new ShadowsocksManagerServiceBuilder()
.metricsPublisher(sharedMetrics)
.build();
service.setShareMetrics(
{params: {metricsEnabled: true}},
{
send: (httpCode, _) => {
expect(httpCode).toEqual(204);
expect(sharedMetrics.isSharingEnabled()).toEqual(true);
responseProcessed = true;
},
},
done
);
});
});
});
describe('bindService', () => {
let server: restify.Server;
let service: ShadowsocksManagerService;
let url: URL;
const PREFIX = '/TestApiPrefix';
const fakeResponse = {foo: 'bar'};
const fakeHandler = async (req, res, next) => {
res.send(200, fakeResponse);
next();
};
beforeEach(() => {
server = restify.createServer();
service = new ShadowsocksManagerServiceBuilder().build();
server.listen(0);
url = new URL(server.url);
});
afterEach(() => {
server.close();
});
it('basic routing', async () => {
spyOn(service, 'renameServer').and.callFake(fakeHandler);
bindService(server, PREFIX, service);
url.pathname = `${PREFIX}/name`;
const response = await fetch(url, {method: 'put'});
const body = await response.json();
expect(body).toEqual(fakeResponse);
expect(service.renameServer).toHaveBeenCalled();
});
it('parameterized routing', async () => {
spyOn(service, 'removeAccessKeyDataLimit').and.callFake(fakeHandler);
bindService(server, PREFIX, service);
url.pathname = `${PREFIX}/access-keys/fake-access-key-id/data-limit`;
const response = await fetch(url, {method: 'delete'});
const body = await response.json();
expect(body).toEqual(fakeResponse);
expect(service.removeAccessKeyDataLimit).toHaveBeenCalled();
});
// Verify that we have consistent 404 behavior for all inputs.
[
'/',
'/TestApiPre',
'/foo',
'/TestApiPrefix123',
'/123TestApiPrefix',
'/very-long-path-that-does-not-exist',
`${PREFIX}/does-not-exist`,
].forEach((path) => {
it(`404 (${path})`, async () => {
// Ensure no methods are called on the Service.
spyOnAllFunctions(service);
jasmine.setDefaultSpyStrategy(fail);
bindService(server, PREFIX, service);
url.pathname = path;
const response = await fetch(url);
const body = await response.json();
expect(response.status).toEqual(404);
expect(body).toEqual({
code: 'ResourceNotFound',
message: `${path} does not exist`,
});
});
});
// This is primarily a reverse testcase for the unauthorized case.
it(`standard routing for authorized queries`, async () => {
bindService(server, PREFIX, service);
// Verify that ordinary routing goes through the Router.
spyOn(server.router, 'lookup').and.callThrough();
// This is an authorized request, so it will pass the prefix filter
// and reach the Router.
url.pathname = `${PREFIX}`;
const response = await fetch(url);
expect(response.status).toEqual(404);
await response.json();
expect(server.router.lookup).toHaveBeenCalled();
});
// Check that unauthorized queries are rejected without ever reaching
// the routing stage.
['/', '/T', '/TestApiPre', '/TestApi123456', '/TestApi123456789'].forEach((path) => {
it(`no routing for unauthorized queries (${path})`, async () => {
bindService(server, PREFIX, service);
// Ensure no methods are called on the Router.
spyOnAllFunctions(server.router);
jasmine.setDefaultSpyStrategy(fail);
// Try bare pathname.
url.pathname = path;
const response1 = await fetch(url);
expect(response1.status).toEqual(404);
await response1.json();
// Try a subpath that would exist if this were a valid prefix
url.pathname = `${path}/server`;
const response2 = await fetch(url);
expect(response2.status).toEqual(404);
await response2.json();
// Try an arbitrary subpath
url.pathname = `${path}/does-not-exist`;
const response3 = await fetch(url);
expect(response3.status).toEqual(404);
await response3.json();
});
});
});
class ShadowsocksManagerServiceBuilder {
private defaultServerName_ = 'default name';
private serverConfig_: JsonConfig<ServerConfigJson> = null;
private accessKeys_: AccessKeyRepository = null;
private managerMetrics_: ManagerMetrics = null;
private metricsPublisher_: SharedMetricsPublisher = null;
defaultServerName(name: string): ShadowsocksManagerServiceBuilder {
this.defaultServerName_ = name;
return this;
}
serverConfig(config: JsonConfig<ServerConfigJson>): ShadowsocksManagerServiceBuilder {
this.serverConfig_ = config;
return this;
}
accessKeys(keys: AccessKeyRepository): ShadowsocksManagerServiceBuilder {
this.accessKeys_ = keys;
return this;
}
managerMetrics(metrics: ManagerMetrics): ShadowsocksManagerServiceBuilder {
this.managerMetrics_ = metrics;
return this;
}
metricsPublisher(publisher: SharedMetricsPublisher): ShadowsocksManagerServiceBuilder {
this.metricsPublisher_ = publisher;
return this;
}
build(): ShadowsocksManagerService {
return new ShadowsocksManagerService(
this.defaultServerName_,
this.serverConfig_,
this.accessKeys_,
this.managerMetrics_,
this.metricsPublisher_
);
}
}
async function createNewAccessKeyWithName(
repo: AccessKeyRepository,
name: string
): Promise<AccessKey> {
const accessKey = await repo.createNewAccessKey();
try {
repo.renameAccessKey(accessKey.id, name);
} catch (e) {
// Ignore; writing to disk is expected to fail in some of the tests.
}
return accessKey;
}
function fakeSharedMetricsReporter(): SharedMetricsPublisher {
let sharing = false;
return {
startSharing() {
sharing = true;
},
stopSharing() {
sharing = false;
},
isSharingEnabled(): boolean {
return sharing;
},
};
}
function getAccessKeyRepository(): ServerAccessKeyRepository {
return new ServerAccessKeyRepository(
OLD_PORT,
'hostname',
new InMemoryConfig<AccessKeyConfigJson>({accessKeys: [], nextId: 0}),
new FakeShadowsocksServer(),
new FakePrometheusClient({})
);
}