Migrate API tests to unit/integration

This excludes route tests or refactoring. Also, created tests
for API middleware.

Change-type: patch
Signed-off-by: Christina Ying Wang <christina@balena.io>
This commit is contained in:
Christina Ying Wang 2022-10-25 02:03:48 +00:00
parent 636d623151
commit 532e75a77e
10 changed files with 323 additions and 292 deletions

View File

@ -0,0 +1,135 @@
import * as express from 'express';
import * as request from 'supertest';
import { expect } from 'chai';
import * as config from '~/src/config';
import * as testDb from '~/src/db';
import * as deviceApi from '~/src/device-api';
import * as middleware from '~/src/device-api/middleware';
import { AuthorizedRequest } from '~/src/device-api/api-keys';
describe('device-api/api-keys', () => {
let app: express.Application;
before(async () => {
await config.initialized();
app = express();
app.use(middleware.auth);
app.get('/test/:appId', (req: AuthorizedRequest, res) => {
if (req.auth.isScoped({ apps: [parseInt(req.params.appId, 10)] })) {
res.sendStatus(200);
} else {
res.sendStatus(401);
}
});
});
afterEach(async () => {
// Delete all scoped API keys between calls to prevent leaking tests
await testDb.models('apiSecret').whereNot({ appId: 0 }).del();
});
it('should generate a key which is scoped for a single application', async () => {
const appOneKey = await deviceApi.generateScopedKey(111, 'one');
const appTwoKey = await deviceApi.generateScopedKey(222, 'two');
await request(app)
.get('/test/111')
.set('Authorization', `Bearer ${appOneKey}`)
.expect(200);
await request(app)
.get('/test/222')
.set('Authorization', `Bearer ${appTwoKey}`)
.expect(200);
await request(app)
.get('/test/222')
.set('Authorization', `Bearer ${appOneKey}`)
.expect(401);
await request(app)
.get('/test/111')
.set('Authorization', `Bearer ${appTwoKey}`)
.expect(401);
});
it('should generate a key which is scoped for multiple applications', async () => {
const multiAppKey = await deviceApi.generateScopedKey(111, 'three', {
scopes: [111, 222].map((appId) => ({ type: 'app', appId })),
});
await request(app)
.get('/test/111')
.set('Authorization', `Bearer ${multiAppKey}`)
.expect(200);
await request(app)
.get('/test/222')
.set('Authorization', `Bearer ${multiAppKey}`)
.expect(200);
});
it('should generate a key which is scoped for all applications', async () => {
await request(app)
.get('/test/111')
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
.expect(200);
await request(app)
.get('/test/222')
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
.expect(200);
});
it('should have a cached lookup of key scopes', async () => {
const globalScopes = await deviceApi.getScopesForKey(
await deviceApi.getGlobalApiKey(),
);
const key = 'my-new-key';
await testDb
.models('apiSecret')
.where({ key: await deviceApi.getGlobalApiKey() })
.update({ key });
// Key has been changed, but cache should retain the old key
expect(
await deviceApi.getScopesForKey(await deviceApi.getGlobalApiKey()),
).to.deep.equal(globalScopes);
// Bust the cache and generate a new global API key
const refreshedKey = await deviceApi.refreshKey(
await deviceApi.getGlobalApiKey(),
);
// Key that we changed in db is no longer valid
expect(await deviceApi.getScopesForKey(key)).to.be.null;
// Refreshed key should have the global scopes
expect(await deviceApi.getScopesForKey(refreshedKey)).to.deep.equal(
globalScopes,
);
});
it('should regenerate a key and invalidate the old one', async () => {
const appScopedKey = await deviceApi.generateScopedKey(111, 'four');
await request(app)
.get('/test/111')
.set('Authorization', `Bearer ${appScopedKey}`)
.expect(200);
const newScopedKey = await deviceApi.refreshKey(appScopedKey);
await request(app)
.get('/test/111')
.set('Authorization', `Bearer ${appScopedKey}`)
.expect(401);
await request(app)
.get('/test/111')
.set('Authorization', `Bearer ${newScopedKey}`)
.expect(200);
});
});

View File

@ -0,0 +1,29 @@
import * as express from 'express';
import * as request from 'supertest';
import * as deviceApi from '~/src/device-api';
describe('device-api/index', () => {
let api: express.Application;
before(async () => {
api = new deviceApi.SupervisorAPI({
routers: [],
healthchecks: [],
// @ts-expect-error
}).api;
// Express app set in SupervisorAPI is private here
// but we need to access it for supertest
});
describe('/ping', () => {
it('responds with 200 regardless of auth', async () => {
await request(api).get('/ping').expect(200);
await request(api)
.get('/ping')
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
.expect(200);
});
});
});

View File

@ -0,0 +1,102 @@
import * as express from 'express';
import * as request from 'supertest';
import * as config from '~/src/config';
import * as testDb from '~/src/db';
import * as deviceApi from '~/src/device-api';
import * as middleware from '~/src/device-api/middleware';
describe('device-api/middleware', () => {
let app: express.Application;
before(async () => {
await config.initialized();
});
describe('auth', () => {
const INVALID_KEY = 'bad_api_secret';
before(() => {
app = express();
app.use(middleware.auth);
app.get('/', (_req, res) => res.sendStatus(200));
});
afterEach(async () => {
// Delete all API keys between calls to prevent leaking tests
await testDb.models('apiSecret').del();
// Reset local mode to default
await config.set({ localMode: false });
});
it('responds with 401 if no API key', async () => {
await request(app).get('/').expect(401);
});
it('validates API key from request query', async () => {
await request(app)
.get(`/?apikey=${await deviceApi.getGlobalApiKey()}`)
.expect(200);
await request(app).get(`/?apikey=${INVALID_KEY}`).expect(401);
// Should not accept case insensitive scheme
const cases = ['ApiKey', 'apiKey', 'APIKEY', 'ApIKeY'];
for (const query of cases) {
await request(app)
.get(`/?${query}=${await deviceApi.getGlobalApiKey()}`)
.expect(401);
}
});
it('validates API key from Authorization header with ApiKey scheme', async () => {
// Should accept case insensitive scheme
const cases = ['ApiKey', 'apikey', 'APIKEY', 'ApIKeY'];
for (const scheme of cases) {
await request(app)
.get('/')
.set(
'Authorization',
`${scheme} ${await deviceApi.getGlobalApiKey()}`,
)
.expect(200);
await request(app)
.get('/')
.set('Authorization', `${scheme} ${INVALID_KEY}`)
.expect(401);
}
});
it('finds API key from Authorization header with Bearer scheme', async () => {
// Should accept case insensitive scheme
const cases: string[] = ['Bearer', 'bearer', 'BEARER', 'BeAReR'];
for (const scheme of cases) {
await request(app)
.get('/')
.set(
'Authorization',
`${scheme} ${await deviceApi.getGlobalApiKey()}`,
)
.expect(200);
await request(app)
.get('/')
.set('Authorization', `${scheme} ${INVALID_KEY}`)
.expect(401);
}
});
it("doesn't validate auth in local mode", async () => {
const testRequest = async (code: number) =>
await request(app)
.get('/')
.set('Authorization', `Bearer ${INVALID_KEY}`)
.expect(code);
await testRequest(401);
await config.set({ localMode: true });
await testRequest(200);
});
});
});

View File

@ -51,7 +51,6 @@ describe('device-state', () => {
).to.not.be.rejected;
await loadTargetFromFile(appsJson);
const targetState = await deviceState.getTarget();
// console.log('TARGET', JSON.stringify(targetState, null, 2));
await expect(
fs.access(appsJsonBackup(appsJson)),
'apps.json.preloaded is created after loading the target',

View File

@ -6,7 +6,7 @@ import { testfs } from 'mocha-pod';
import { expect } from 'chai';
import Log from '~/lib/supervisor-console';
import * as network from '~/src/network';
import * as constants from '~/src/lib/constants';
import * as constants from '~/lib/constants';
describe('network', () => {
it('checks VPN connection status', async () => {

View File

@ -5,7 +5,7 @@ import * as _ from 'lodash';
import * as apiBinder from '~/src/api-binder';
import * as applicationManager from '~/src/compose/application-manager';
import * as deviceState from '~/src/device-state';
import * as constants from '~/src/lib/constants';
import * as constants from '~/lib/constants';
import { docker } from '~/lib/docker-utils';
import { Supervisor } from '~/src/supervisor';

View File

@ -1,288 +0,0 @@
import { expect } from 'chai';
import { spy } from 'sinon';
import * as supertest from 'supertest';
import mockedAPI = require('~/test-lib/mocked-device-api');
import * as apiBinder from '~/src/api-binder';
import * as deviceState from '~/src/device-state';
import Log from '~/lib/supervisor-console';
import * as db from '~/src/db';
import SupervisorAPI from '~/src/device-api';
import * as deviceApi from '~/src/device-api';
const mockedOptions = {
listenPort: 54321,
timeout: 30000,
};
describe('SupervisorAPI', () => {
let api: SupervisorAPI;
const request = supertest(`http://127.0.0.1:${mockedOptions.listenPort}`);
before(async () => {
await apiBinder.initialized();
await deviceState.initialized();
// The mockedAPI contains stubs that might create unexpected results
// See the module to know what has been stubbed
api = await mockedAPI.create();
// Start test API
await api.listen(mockedOptions.listenPort, mockedOptions.timeout);
});
after(async () => {
try {
await api.stop();
} catch (e: any) {
if (e.message !== 'Server is not running.') {
throw e;
}
}
// Remove any test data generated
await mockedAPI.cleanUp();
});
describe('/ping', () => {
it('responds with OK (without auth)', async () => {
await request.get('/ping').set('Accept', 'application/json').expect(200);
});
it('responds with OK (with auth)', async () => {
await request
.get('/ping')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
.expect(200);
});
});
describe('API Key Scope', () => {
it('should generate a key which is scoped for a single application', async () => {
// single app scoped key...
const appScopedKey = await deviceApi.generateScopedKey(1, 'main');
await request
.get('/v2/applications/1/state')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${appScopedKey}`)
.expect(200);
});
it('should generate a key which is scoped for multiple applications', async () => {
// multi-app scoped key...
const multiAppScopedKey = await deviceApi.generateScopedKey(1, 'other', {
scopes: [1, 2].map((appId) => {
return { type: 'app', appId };
}),
});
await request
.get('/v2/applications/1/state')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${multiAppScopedKey}`)
.expect(200);
await request
.get('/v2/applications/2/state')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${multiAppScopedKey}`)
.expect(200);
});
it('should generate a key which is scoped for all applications', async () => {
await request
.get('/v2/applications/1/state')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
.expect(200);
await request
.get('/v2/applications/2/state')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
.expect(200);
});
it('should have a cached lookup of the key scopes to save DB loading', async () => {
const scopes = await deviceApi.getScopesForKey(
await deviceApi.getGlobalApiKey(),
);
const key = 'not-a-normal-key';
await db.initialized();
await db
.models('apiSecret')
.update({
key,
})
.where({
key: await deviceApi.getGlobalApiKey(),
});
// the key we had is now gone, but the cache should return values
const cachedScopes = await deviceApi.getScopesForKey(
await deviceApi.getGlobalApiKey(),
);
expect(cachedScopes).to.deep.equal(scopes);
// this should bust the cache...
await deviceApi.refreshKey(await deviceApi.getGlobalApiKey());
// the key we changed should be gone now, and the new key should have the cloud scopes
const missingScopes = await deviceApi.getScopesForKey(key);
const freshScopes = await deviceApi.getScopesForKey(
await deviceApi.getGlobalApiKey(),
);
expect(missingScopes).to.be.null;
expect(freshScopes).to.deep.equal(scopes);
});
it('should regenerate a key and invalidate the old one', async () => {
// single app scoped key...
const appScopedKey = await deviceApi.generateScopedKey(1, 'main');
await request
.get('/v2/applications/1/state')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${appScopedKey}`)
.expect(200);
const newScopedKey = await deviceApi.refreshKey(appScopedKey);
await request
.get('/v2/applications/1/state')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${appScopedKey}`)
.expect(401);
await request
.get('/v2/applications/1/state')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${newScopedKey}`)
.expect(200);
});
});
describe('State change logging', () => {
before(() => {
// Spy on functions we will be testing
spy(Log, 'info');
spy(Log, 'error');
});
beforeEach(async () => {
// Start each case with API stopped
try {
await api.stop();
} catch (e: any) {
if (e.message !== 'Server is not running.') {
throw e;
}
}
});
after(async () => {
// @ts-expect-error
Log.info.restore();
// @ts-expect-error
Log.error.restore();
// Resume API for other test suites
return api.listen(mockedOptions.listenPort, mockedOptions.timeout);
});
it('logs successful start', async () => {
// Start API
await api.listen(mockedOptions.listenPort, mockedOptions.timeout);
// Check if success start was logged
// @ts-expect-error
expect(Log.info.lastCall?.lastArg).to.equal(
`Supervisor API successfully started on port ${mockedOptions.listenPort}`,
);
});
it('logs shutdown', async () => {
// Start API
await api.listen(mockedOptions.listenPort, mockedOptions.timeout);
// Stop API
await api.stop();
// Check if stopped with info was logged
// @ts-expect-error
expect(Log.info.lastCall?.lastArg).to.equal('Stopped Supervisor API');
});
it('logs errored shutdown', async () => {
// Start API
await api.listen(mockedOptions.listenPort, mockedOptions.timeout);
// Stop API with error
await api.stop({ errored: true });
// Check if stopped with error was logged
// @ts-expect-error
expect(Log.error.lastCall?.lastArg).to.equal('Stopped Supervisor API');
});
});
describe('Authentication', () => {
const INVALID_SECRET = 'bad_api_secret';
it('finds no apiKey and rejects', async () => {
return request.post('/v1/blink').expect(401);
});
it('finds apiKey from query', async () => {
return request
.post(`/v1/blink?apikey=${await deviceApi.getGlobalApiKey()}`)
.expect(200);
});
it('finds apiKey from Authorization header (ApiKey scheme)', async () => {
return request
.post('/v1/blink')
.set('Authorization', `ApiKey ${await deviceApi.getGlobalApiKey()}`)
.expect(200);
});
it('finds apiKey from Authorization header (Bearer scheme)', async () => {
return request
.post('/v1/blink')
.set('Authorization', `Bearer ${await deviceApi.getGlobalApiKey()}`)
.expect(200);
});
it('finds apiKey from Authorization header (case insensitive)', async () => {
const randomCases = [
'Bearer',
'bearer',
'BEARER',
'BeAReR',
'ApiKey',
'apikey',
'APIKEY',
'ApIKeY',
];
for (const scheme of randomCases) {
return request
.post('/v1/blink')
.set(
'Authorization',
`${scheme} ${await deviceApi.getGlobalApiKey()}`,
)
.expect(200);
}
});
it('rejects invalid apiKey from query', async () => {
return request.post(`/v1/blink?apikey=${INVALID_SECRET}`).expect(401);
});
it('rejects invalid apiKey from Authorization header (ApiKey scheme)', async () => {
return request
.post('/v1/blink')
.set('Authorization', `ApiKey ${INVALID_SECRET}`)
.expect(401);
});
it('rejects invalid apiKey from Authorization header (Bearer scheme)', async () => {
return request
.post('/v1/blink')
.set('Authorization', `Bearer ${INVALID_SECRET}`)
.expect(401);
});
});
});

View File

@ -3,7 +3,7 @@
import { stub, SinonStub } from 'sinon';
import * as dbus from 'dbus';
import { Error as DBusError, DBusInterface } from 'dbus';
import { initialized } from '~/src/lib/dbus';
import { initialized } from '~/lib/dbus';
let getBusStub: SinonStub;

View File

@ -17,6 +17,7 @@ export const mochaHooks = {
sinon.stub(log, 'success');
sinon.stub(log, 'event');
sinon.stub(log, 'error');
sinon.stub(log, 'api');
},
afterEach() {
@ -26,6 +27,7 @@ export const mochaHooks = {
(log.success as sinon.SinonStub).reset();
(log.event as sinon.SinonStub).reset();
(log.error as sinon.SinonStub).reset();
(log.api as sinon.SinonStub).reset();
},
afterAll() {
@ -35,5 +37,6 @@ export const mochaHooks = {
(log.success as sinon.SinonStub).restore();
(log.event as sinon.SinonStub).restore();
(log.error as sinon.SinonStub).restore();
(log.api as sinon.SinonStub).restore();
},
};

View File

@ -0,0 +1,51 @@
import * as express from 'express';
import * as request from 'supertest';
import { SinonStub } from 'sinon';
import { expect } from 'chai';
import * as middleware from '~/src/device-api/middleware';
import { UpdatesLockedError } from '~/lib/errors';
import log from '~/lib/supervisor-console';
describe('device-api/middleware', () => {
let app: express.Application;
describe('errors', () => {
before(() => {
app = express();
app.get('/locked', (_req, _res) => {
throw new UpdatesLockedError();
});
app.get('/errored', (_req, _res) => {
throw new Error();
});
app.use(middleware.errors);
});
it('responds with 423 if updates are locked', async () => {
await request(app).get('/locked').expect(423);
});
it('responds with 503 if any other error', async () => {
await request(app).get('/errored').expect(503);
});
});
describe('logging', () => {
before(() => {
app = express();
app.use(middleware.logging);
app.get('/', (_req, res) => res.sendStatus(200));
app.post('/', (_req, res) => res.sendStatus(304));
(log.api as SinonStub).reset();
});
it('logs API request methods and status codes', async () => {
await request(app).get('/');
expect((log.api as SinonStub).lastCall?.firstArg).to.match(/get.*200/i);
await request(app).post('/');
expect((log.api as SinonStub).lastCall?.firstArg).to.match(/post.*304/i);
});
});
});