1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-12-28 00:06:53 +01:00

Merge pull request #283 from Unleash/admin_auth

[Auth] Support for providing custom Oauth 2.0 provider
This commit is contained in:
Ivar Conradi Østhus 2018-01-17 15:58:57 +01:00 committed by GitHub
commit 93410de68d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 1032 additions and 219 deletions

View File

@ -1,5 +1,13 @@
# Changelog
## 3.0.0-alpha.8
- [Auth] User-provider ([#261](https://github.com/Unleash/unleash/issues/261))
- [Auth] Document how to secure Unleash ([#234](https://github.com/Unleash/unleash/issues/234))
- [Auth] Admin UI should handle 401 ([#232](https://github.com/Unleash/unleash/issues/232))
- [Auth] Client API authentication ([#231](https://github.com/Unleash/unleash/issues/231))
- [Auth] Handle 403 (Forbidden) with custom auth.
- [Auth] Support sign out ([#288](https://github.com/Unleash/unleash/issues/288))
## 3.0.0-alpha.7
- Bugfix: Should not allow creation of archived toggle #284

View File

@ -41,6 +41,11 @@ Available unleash options includes:
- **serverMetrics** (boolean) - Use this option to turn of prometheus metrics.
- **preHook** (function) - This is a hook if you need to provide any middlewares to express before `unleash` adds any. Express app instance is injected as first arguement.
- **preRouterHook** (function) - Use this to register custom express middlewares before the `unleash` specific routers are added. This is typically how you would register custom middlewares to handle authentication.
- **secret** (string) - Set this when you want to secure unleash. Used to encrypt the user session.
- **adminAuthentication** (string) - Use this when implementing cusotm admin authentication [securing-unleash](./securing-unleash.md). Legal values are:
- `none` - Will disable autentication all together
- `unsecure` - (default) Will use simple cookie based authentication. UI will require the user to specify an email in order to use unleash.
- `custom` - Use this when you implement your own custom authentication logic.
## How do I configure the log output?

70
docs/securing-unleash.md Normal file
View File

@ -0,0 +1,70 @@
# Secure Unleash
The Unleash API is split in two different paths: `/api/client` and `/api/admin`.
This makes it easy to have different authentication strategy for the admin interface and the client-api used by the applications integrating with Unleash.
## General settings
Unleash uses an encrypted cookie to maintain a user session. This allows users to be logged in across instances of Unleash. To protect this cookie you should specify the `secret` option when starting unleash.-
## Securing the Admin API
In order to secure the Admin API you have to tell Unleash that you are using a custom admin authentication and implement your authentication logic as a preHook. You should also set the secret option to a protected secret in your system.
```javascript
const unleash = require('unleash-server');
const myCustomAdminAuth = require('./auth-hook');
unleash.start({
databaseUrl: 'postgres://unleash_user:passord@localhost:5432/unleash',
secret: 'super-duper-secret',
adminAuthentication: 'custom',
preRouterHook: myCustomAdminAuth
}).then(unleash => {
console.log(`Unleash started on http://localhost:${unleash.app.get('port')}`);
});
```
Examples on custom authentication hooks:
- [google-auth-hook.js](https://github.com/Unleash/unleash/blob/master/examples/google-auth-hook.js)
- [basic-auth-hook.js](https://github.com/Unleash/unleash/blob/master/examples/basic-auth-hook.js)
## Securing the Client API
A common way to support client access is to use pre shared secrets. This can be solved by having clients send a shared key in a http header with every client requests to the Unleash API. All official Unleash clients should support this.
In the [Java client](https://github.com/Unleash/unleash-client-java#custom-http-headers) this looks like:
```java
UnleashConfig unleashConfig = UnleashConfig.builder()
.appName("my-app")
.instanceId("my-instance-1")
.unleashAPI(unleashAPI)
.customHttpHeader("Authorization", "12312Random")
.build();
```
On the unleash server side you need to implement a preRouter hook which verifies that all calls to `/api/client` includes this pre shared key in the defined header. This could look something like this:
```javascript
const unleash = require('unleash-server');
const sharedSecret = '12312Random';
unleash.start({
databaseUrl: 'postgres://unleash_user:passord@localhost:5432/unleash',
enableLegacyRoutes: false,
preRouterHook: (app) => {
app.use('/api/client', (req, res, next) => {
if(req.headers.authorization !== sharedSecret) {
res.sendStatus(401);
} else {
next()
}
});
}
}).then(unleash => {
console.log(`Unleash started on http://localhost:${unleash.app.get('port')}`);
});
```
[client-auth-unleash.js](https://github.com/Unleash/unleash/blob/master/examples/client-auth-unleash.js)
PS! Remember to disable legacy route with by setting the `enableLegacyRoutes` option to false. This will require all your clients to be on v3.x.

View File

@ -0,0 +1,30 @@
'use strict';
const auth = require('basic-auth');
const { User } = require('../lib/server-impl.js');
function basicAuthentication(app) {
app.use('/api/admin/', (req, res, next) => {
const credentials = auth(req);
if (credentials) {
// you will need to do some verification of credentials here.
const user = new User({ email: `${credentials.name}@domain.com` });
req.user = user;
next();
} else {
return res
.status('401')
.set({ 'WWW-Authenticate': 'Basic realm="example"' })
.end('access denied');
}
});
app.use((req, res, next) => {
// Updates active sessions every hour
req.session.nowInHours = Math.floor(Date.now() / 3600e3);
next();
});
}
module.exports = basicAuthentication;

View File

@ -0,0 +1,19 @@
'use strict';
// const unleash = require('unleash-server');
const unleash = require('../lib/server-impl.js');
const basicAuth = require('./basic-auth-hook');
unleash
.start({
databaseUrl: 'postgres://unleash_user:passord@localhost:5432/unleash',
secret: 'super-duper-secret',
adminAuthentication: 'custom',
preRouterHook: basicAuth,
})
.then(server => {
console.log(
`Unleash started on http://localhost:${server.app.get('port')}`
);
});

View File

@ -0,0 +1,27 @@
'use strict';
// const unleash = require('unleash-server');
const unleash = require('../lib/server-impl.js');
// You typically will not hard-code this value in your code!
const sharedSecret = '12312Random';
unleash
.start({
databaseUrl: 'postgres://unleash_user:passord@localhost:5432/unleash',
enableLegacyRoutes: false,
preRouterHook: app => {
app.use('/api/client', (req, res, next) => {
if (req.headers.authorization === sharedSecret) {
next();
} else {
res.sendStatus(401);
}
});
},
})
.then(server => {
console.log(
`Unleash started on http://localhost:${server.app.get('port')}`
);
});

View File

@ -0,0 +1,85 @@
'use strict';
/**
* Google OAath 2.0
*
* You should read Using OAuth 2.0 to Access Google APIs:
* https://developers.google.com/identity/protocols/OAuth2
*
* This example assumes that all users authenticating via
* google should have access. You would proably limit access
* to users you trust.
*
* The implementation assumes the following environement variables:
*
* - GOOGLE_CLIENT_ID
* - GOOGLE_CLIENT_SECRET
* - GOOGLE_CALLBACK_URL
*/
// const { User, AuthenticationRequired } = require('unleash-server');
const { User, AuthenticationRequired } = require('../lib/server-impl.js');
const passport = require('passport');
const GoogleOAuth2Strategy = require('passport-google-auth').Strategy;
passport.use(
new GoogleOAuth2Strategy(
{
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: process.env.GOOGLE_CALLBACK_URL,
},
(accessToken, refreshToken, profile, done) => {
done(
null,
new User({
name: profile.displayName,
email: profile.emails[0].value,
})
);
}
)
);
function enableGoogleOauth(app) {
app.use(passport.initialize());
app.use(passport.session());
passport.serializeUser((user, done) => done(null, user));
passport.deserializeUser((user, done) => done(null, user));
app.get('/api/admin/login', passport.authenticate('google'));
app.get(
'/api/auth/callback',
passport.authenticate('google', {
failureRedirect: '/api/admin/error-login',
}),
(req, res) => {
// Successful authentication, redirect to your app.
res.redirect('/');
}
);
app.use('/api/admin/', (req, res, next) => {
if (req.user) {
next();
} else {
// Instruct unleash-frontend to pop-up auth dialog
return res
.status('401')
.json(
new AuthenticationRequired({
path: '/api/admin/login',
type: 'custom',
message: `You have to identify yourself in order to use Unleash.
Click the button and follow the instructions.`,
})
)
.end();
}
});
}
module.exports = enableGoogleOauth;

View File

@ -0,0 +1,19 @@
'use strict';
// const unleash = require('unleash-server');
const unleash = require('../lib/server-impl.js');
const enableGoogleOauth = require('./google-auth-hook');
unleash
.start({
databaseUrl: 'postgres://unleash_user:passord@localhost:5432/unleash',
secret: 'super-duper-secret',
adminAuthentication: 'custom',
preRouterHook: enableGoogleOauth,
})
.then(server => {
console.log(
`Unleash started on http://localhost:${server.app.get('port')}`
);
});

View File

@ -11,6 +11,7 @@ const unleashSession = require('./middleware/session');
const responseTime = require('./middleware/response-time');
const requestLogger = require('./middleware/request-logger');
const validator = require('./middleware/validator');
const simpleAuthentication = require('./middleware/simple-authentication');
module.exports = function(config) {
const app = express();
@ -38,6 +39,10 @@ module.exports = function(config) {
app.use(baseUriPath, express.static(config.publicFolder));
}
if (config.adminAuthentication === 'unsecure') {
simpleAuthentication(app);
}
if (typeof config.preRouterHook === 'function') {
config.preRouterHook(app);
}

View File

@ -0,0 +1,9 @@
'use strict';
module.exports = class AuthenticationRequired {
constructor({ type, path, message }) {
this.type = type;
this.path = path;
this.message = message;
}
};

View File

@ -1,6 +1,7 @@
'use strict';
function extractUsername(req) {
return req.cookies.username || 'unknown';
return req.user ? req.user.email : 'unknown';
}
module.exports = extractUsername;

View File

@ -0,0 +1,48 @@
'use strict';
const User = require('../user');
const AuthenticationRequired = require('../authentication-required');
function unsecureAuthentication(app) {
app.post('/api/admin/login', (req, res) => {
const user = req.body;
req.session.user = new User({ email: user.email });
res
.status(200)
.json(req.session.user)
.end();
});
app.use('/api/admin/', (req, res, next) => {
if (req.session.user && req.session.user.email) {
req.user = req.session.user;
}
next();
});
app.use('/api/admin/', (req, res, next) => {
if (req.user) {
next();
} else {
return res
.status('401')
.json(
new AuthenticationRequired({
path: '/api/admin/login',
type: 'unsecure',
message:
'You have to indentify yourself in order to use Unleash.',
})
)
.end();
}
});
app.use((req, res, next) => {
// Updates active sessions every hour
req.session.nowInHours = Math.floor(Date.now() / 3600e3);
next();
});
}
module.exports = unsecureAuthentication;

View File

@ -15,6 +15,7 @@ const DEFAULT_OPTIONS = {
enableRequestLogger: isDev(),
secret: 'UNLEASH-SECRET',
sessionAge: THIRTY_DAYS,
adminAuthentication: 'unsecure',
};
module.exports = {

View File

@ -7,6 +7,7 @@ const featureArchive = require('./archive.js');
const events = require('./event.js');
const strategies = require('./strategy');
const metrics = require('./metrics');
const user = require('./user');
const apiDef = {
version: 2,
@ -31,6 +32,7 @@ exports.router = config => {
router.use('/strategies', strategies.router(config));
router.use('/events', events.router(config));
router.use('/metrics', metrics.router(config));
router.use('/user', user.router(config));
return router;
};

View File

@ -4,7 +4,11 @@ const { Router } = require('express');
const logger = require('../../logger')('/admin-api/metrics.js');
const ClientMetrics = require('../../client-metrics');
const { catchLogAndSendErrorResponse } = require('./route-utils');
const catchLogAndSendErrorResponse = (err, res) => {
logger.error(err);
res.status(500).end();
};
exports.router = function(config) {
const {
@ -67,10 +71,7 @@ exports.router = function(config) {
clientApplicationsStore
.upsert(input)
.then(() => res.status(202).end())
.catch(e => {
logger.error(e);
res.status(500).end();
});
.catch(err => catchLogAndSendErrorResponse(err, res));
});
function toLookup(metaData) {

View File

@ -93,3 +93,31 @@ test('should return metrics for all toggles', t => {
t.true(metrics.lastMinute !== undefined);
});
});
test('should return applications', t => {
t.plan(2);
const { request, stores } = getSetup();
const appName = '123!23';
stores.clientApplicationsStore.upsert({ appName });
return request
.get(`/api/admin/metrics/applications/`)
.expect(200)
.expect(res => {
const metrics = res.body;
t.true(metrics.applications.length === 1);
t.true(metrics.applications[0].appName === appName);
});
});
test('should store application', t => {
t.plan(0);
const { request } = getSetup();
const appName = '123!23';
return request
.post(`/api/admin/metrics/applications/${appName}`)
.send({ appName })
.expect(202);
});

View File

@ -1,10 +0,0 @@
'use strict';
const logger = require('../../logger')('route-utils.js');
const catchLogAndSendErrorResponse = (err, res) => {
logger.error(err);
res.status(500).end();
};
module.exports = { catchLogAndSendErrorResponse };

View File

@ -0,0 +1,27 @@
'use strict';
const { Router } = require('express');
exports.router = function() {
const router = Router();
router.get('/', (req, res) => {
if (req.user) {
return res
.status(200)
.json(req.user)
.end();
} else {
return res.status(404).end();
}
});
router.get('/logout', (req, res) => {
if (req.session) {
req.session = null;
}
res.redirect('/');
});
return router;
};

View File

@ -0,0 +1,57 @@
'use strict';
const { test } = require('ava');
const store = require('./../../../test/fixtures/store');
const supertest = require('supertest');
const getApp = require('../../app');
const User = require('../../user');
const { EventEmitter } = require('events');
const eventBus = new EventEmitter();
const currentUser = new User({ email: 'test@mail.com' });
function getSetup() {
const base = `/random${Math.round(Math.random() * 1000)}`;
const stores = store.createStores();
const app = getApp({
baseUriPath: base,
stores,
eventBus,
preHook: a => {
a.use((req, res, next) => {
req.user = currentUser;
next();
});
},
});
return {
base,
strategyStore: stores.strategyStore,
request: supertest(app),
};
}
test('should return current user', t => {
t.plan(1);
const { request, base } = getSetup();
return request
.get(`${base}/api/admin/user`)
.expect(200)
.expect('Content-Type', /json/)
.expect(res => {
t.true(res.body.email === currentUser.email);
});
});
test('should logout and redirect', t => {
t.plan(0);
const { request, base } = getSetup();
return request
.get(`${base}/api/admin/user/logout`)
.expect(302)
.expect('Location', '/');
});

View File

@ -9,6 +9,8 @@ const getApp = require('./app');
const { startMonitoring } = require('./metrics');
const { createStores } = require('./db');
const { createOptions } = require('./options');
const User = require('./user');
const AuthenticationRequired = require('./authentication-required');
function createApp(options) {
// Database dependecies (statefull)
@ -44,4 +46,6 @@ function start(opts) {
module.exports = {
start,
User,
AuthenticationRequired,
};

20
lib/user.js Normal file
View File

@ -0,0 +1,20 @@
'use strict';
const gravatar = require('gravatar');
const Joi = require('joi');
module.exports = class User {
constructor({ name, email, imageUrl } = {}) {
Joi.assert(
email,
Joi.string()
.email()
.required(),
'Email'
);
this.email = email;
this.name = name;
this.imageUrl =
imageUrl || gravatar.url(email, { s: '42', d: 'retro' });
}
};

28
lib/user.test.js Normal file
View File

@ -0,0 +1,28 @@
'use strict';
const { test } = require('ava');
const User = require('./user');
test('should create user', t => {
const user = new User({ name: 'ole', email: 'some@email.com' });
t.is(user.name, 'ole');
t.is(user.email, 'some@email.com');
t.is(
user.imageUrl,
'//www.gravatar.com/avatar/d8ffeba65ee5baf57e4901690edc8e1b?s=42&d=retro'
);
});
test('should require email', t => {
const error = t.throws(() => {
const user = new User(); // eslint-disable-line
}, Error);
t.is(error.message, 'Email "value" is required');
});
test('Should create user with only email defined', t => {
const user = new User({ email: 'some@email.com' });
t.is(user.email, 'some@email.com');
});

View File

@ -33,6 +33,7 @@
},
"scripts": {
"start": "node server.js",
"start:google": "node examples/google-auth-unleash.js",
"start:dev": "NODE_ENV=development supervisor --ignore ./node_modules/ server.js",
"start:dev:pg": "pg_virtualenv npm run start:dev:pg-chain",
"start:dev:pg-chain": "export DATABASE_URL=postgres://$PGUSER:$PGPASSWORD@localhost:$PGPORT/postgres ; db-migrate up && npm run start:dev",
@ -67,19 +68,22 @@
"errorhandler": "^1.5.0",
"express": "^4.16.2",
"express-validator": "^4.3.0",
"gravatar": "^1.6.0",
"install": "^0.10.1",
"joi": "^13.0.1",
"knex": "^0.14.0",
"log4js": "^2.0.0",
"moment": "^2.19.3",
"parse-database-url": "^0.3.0",
"passport": "^0.4.0",
"passport-google-auth": "^1.0.2",
"pg": "^7.4.0",
"pkginfo": "^0.4.1",
"prom-client": "^10.0.4",
"prometheus-gc-stats": "^0.5.0",
"response-time": "^2.3.2",
"serve-favicon": "^2.3.0",
"unleash-frontend": "^3.0.0-alpha.4",
"unleash-frontend": "^3.0.0-alpha.5",
"yallist": "^3.0.2",
"yargs": "^10.0.3"
},

View File

@ -0,0 +1,36 @@
'use strict';
const { test } = require('ava');
const { setupAppWithAuth } = require('./../../helpers/test-helper');
test.serial('creates new feature toggle with createdBy', async t => {
t.plan(1);
const { request, destroy } = await setupAppWithAuth('feature_api_auth');
// Login
await request.post('/api/admin/login').send({
email: 'user@mail.com',
});
// create toggle
await request.post('/api/admin/features').send({
name: 'com.test.Username',
enabled: false,
strategies: [{ name: 'default' }],
});
await request
.get('/api/admin/events')
.expect(res => {
t.true(res.body.events[0].createdBy === 'user@mail.com');
})
.then(destroy);
});
test.serial('should require authenticated user', async t => {
t.plan(0);
const { request, destroy } = await setupAppWithAuth('feature_api_auth');
return request
.get('/api/admin/features')
.expect(401)
.then(destroy);
});

View File

@ -0,0 +1,62 @@
'use strict';
const { test } = require('ava');
const { setupAppWithCustomAuth } = require('./../../helpers/test-helper');
const AuthenticationRequired = require('./../../../../lib/authentication-required');
const User = require('./../../../../lib/user');
test.serial('should require authenticated user', async t => {
t.plan(0);
const preHook = app => {
app.use('/api/admin/', (req, res) =>
res
.status('401')
.json(
new AuthenticationRequired({
path: '/api/admin/login',
type: 'custom',
message: `You have to identify yourself.`,
})
)
.end()
);
};
const { request, destroy } = await setupAppWithCustomAuth(
'feature_api_custom_auth',
preHook
);
return request
.get('/api/admin/features')
.expect(401)
.then(destroy);
});
test.serial('creates new feature toggle with createdBy', async t => {
t.plan(1);
const user = new User({ email: 'custom-user@mail.com' });
const preHook = app => {
app.use('/api/admin/', (req, res, next) => {
req.user = user;
next();
});
};
const { request, destroy } = await setupAppWithCustomAuth(
'feature_api_custom_auth',
preHook
);
// create toggle
await request.post('/api/admin/features').send({
name: 'com.test.Username',
enabled: false,
strategies: [{ name: 'default' }],
});
await request
.get('/api/admin/events')
.expect(res => {
t.true(res.body.events[0].createdBy === user.email);
})
.then(destroy);
});

View File

@ -50,22 +50,18 @@ test.serial('creates new feature toggle', async t => {
.then(destroy);
});
test.serial('creates new feature toggle with createdBy', async t => {
test.serial('creates new feature toggle with createdBy unknown', async t => {
t.plan(1);
const { request, destroy } = await setupApp('feature_api_serial');
await request
.post('/api/admin/features')
.send({
name: 'com.test.Username',
enabled: false,
strategies: [{ name: 'default' }],
})
.set('Cookie', ['username=ivaosthu'])
.set('Content-Type', 'application/json');
await request.post('/api/admin/features').send({
name: 'com.test.Username',
enabled: false,
strategies: [{ name: 'default' }],
});
await request
.get('/api/admin/events')
.expect(res => {
t.true(res.body.events[0].createdBy === 'ivaosthu');
t.true(res.body.events[0].createdBy === 'unknown');
})
.then(destroy);
});

View File

@ -1,5 +1,36 @@
'use strict';
const { test } = require('ava');
const { setupApp } = require('./../../helpers/test-helper');
test.todo('e2e client feature');
test.serial('returns three feature toggles', async t => {
const { request, destroy } = await setupApp('feature_api_client');
return request
.get('/api/client/features')
.expect('Content-Type', /json/)
.expect(200)
.expect(res => {
t.true(res.body.features.length === 3);
})
.then(destroy);
});
test.serial('gets a feature by name', async t => {
t.plan(0);
const { request, destroy } = await setupApp('feature_api_client');
return request
.get('/api/client/features/featureX')
.expect('Content-Type', /json/)
.expect(200)
.then(destroy);
});
test.serial('cant get feature that dose not exist', async t => {
t.plan(0);
const { request, destroy } = await setupApp('feature_api_client');
return request
.get('/api/client/features/myfeature')
.expect('Content-Type', /json/)
.expect(404)
.then(destroy);
});

View File

@ -0,0 +1,70 @@
'use strict';
const migrator = require('../../../migrator');
const { createStores } = require('../../../lib/db');
const { createDb } = require('../../../lib/db/db-pool');
const dbState = require('./database.json');
require('db-migrate-shared').log.silence(true);
// because of migrator bug
delete process.env.DATABASE_URL;
// because of db-migrate bug (https://github.com/Unleash/unleash/issues/171)
process.setMaxListeners(0);
async function resetDatabase(stores) {
return Promise.all([
stores.db('strategies').del(),
stores.db('features').del(),
stores.db('client_applications').del(),
stores.db('client_instances').del(),
]);
}
async function setupDatabase(stores) {
const updates = [];
updates.push(...createStrategies(stores.strategyStore));
updates.push(...createFeatures(stores.featureToggleStore));
updates.push(...createClientInstance(stores.clientInstanceStore));
updates.push(...createApplications(stores.clientApplicationsStore));
await Promise.all(updates);
}
function createStrategies(store) {
return dbState.strategies.map(s => store._createStrategy(s));
}
function createApplications(store) {
return dbState.applications.map(a => store.upsert(a));
}
function createClientInstance(store) {
return dbState.clientInstances.map(i => store.insert(i));
}
function createFeatures(store) {
return dbState.features.map(f => store._createFeature(f));
}
module.exports = async function init(databaseSchema = 'test') {
const options = {
databaseUrl: require('./database-config').getDatabaseUrl(),
databaseSchema,
minPool: 0,
maxPool: 0,
};
const db = createDb(options);
await db.raw(`CREATE SCHEMA IF NOT EXISTS ${options.databaseSchema}`);
await migrator(options);
await db.destroy();
const stores = await createStores(options);
await resetDatabase(stores);
await setupDatabase(stores);
return stores;
};

View File

@ -0,0 +1,113 @@
{
"strategies": [
{
"name": "default",
"description": "Default on or off Strategy.",
"parameters": []
},
{
"name": "usersWithEmail",
"description": "Active for users defined in the comma-separated emails-parameter.",
"parameters": [{
"name": "emails",
"type": "string"
}]
}
],
"applications": [
{
"appName": "demo-app-1",
"strategies": ["default"]
},
{
"appName": "demo-app-2",
"strategies": ["default",
"extra"
],
"description": "hello"
}
],
"clientInstances": [
{
"appName": "demo-app-1",
"instanceId": "test-1",
"strategies": ["default"],
"started": 1516026938494,
"interval": 10
},
{
"appName": "demo-seed-2",
"instanceId": "test-2",
"strategies": ["default"],
"started": 1516026938494,
"interval": 10
}
],
"features": [
{
"name": "featureX",
"description": "the #1 feature",
"enabled": true,
"strategies": [{
"name": "default",
"parameters": {}
}]
},
{
"name": "featureY",
"description": "soon to be the #1 feature",
"enabled": false,
"strategies": [{
"name": "baz",
"parameters": {
"foo": "bar"
}
}]
},
{
"name": "featureZ",
"description": "terrible feature",
"enabled": true,
"strategies": [{
"name": "baz",
"parameters": {
"foo": "rab"
}
}]
},
{
"name": "featureArchivedX",
"description": "the #1 feature",
"enabled": true,
"archived": true,
"strategies": [{
"name": "default",
"parameters": {}
}]
},
{
"name": "featureArchivedY",
"description": "soon to be the #1 feature",
"enabled": false,
"archived": true,
"strategies": [{
"name": "baz",
"parameters": {
"foo": "bar"
}
}]
},
{
"name": "featureArchivedZ",
"description": "terrible feature",
"enabled": true,
"archived": true,
"strategies": [{
"name": "baz",
"parameters": {
"foo": "rab"
}
}]
}
]
}

View File

@ -2,198 +2,52 @@
process.env.NODE_ENV = 'test';
// because of db-migrate bug (https://github.com/Unleash/unleash/issues/171)
process.setMaxListeners(0);
const supertest = require('supertest');
const migrator = require('../../../migrator');
const { createStores } = require('../../../lib/db');
const { createDb } = require('../../../lib/db/db-pool');
const getApp = require('../../../lib/app');
require('db-migrate-shared').log.silence(true);
// because of migrator bug
delete process.env.DATABASE_URL;
const getApp = require('../../../lib/app');
const dbInit = require('./database-init');
const { EventEmitter } = require('events');
const eventBus = new EventEmitter();
function createApp(databaseSchema = 'test') {
const options = {
databaseUrl: require('./database-config').getDatabaseUrl(),
databaseSchema,
minPool: 0,
maxPool: 0,
};
const db = createDb({
databaseUrl: options.databaseUrl,
minPool: 0,
maxPool: 0,
function createApp(stores, adminAuthentication = 'none', preHook) {
return getApp({
stores,
eventBus,
preHook,
adminAuthentication,
secret: 'super-secret',
sessionAge: 4000,
});
return db
.raw(`CREATE SCHEMA IF NOT EXISTS ${options.databaseSchema}`)
.then(() => migrator(options))
.then(() => {
db.destroy();
const stores = createStores(options);
const app = getApp({ stores, eventBus });
return {
stores,
request: supertest(app),
destroy() {
return stores.db.destroy();
},
};
});
}
function createStrategies(stores) {
return [
{
name: 'default',
description: 'Default on or off Strategy.',
parameters: [],
},
{
name: 'usersWithEmail',
description:
'Active for users defined in the comma-separated emails-parameter.',
parameters: [{ name: 'emails', type: 'string' }],
},
].map(strategy => stores.strategyStore._createStrategy(strategy));
}
function createApplications(stores) {
return [
{
appName: 'demo-app-1',
strategies: ['default'],
},
{
appName: 'demo-app-2',
strategies: ['default', 'extra'],
description: 'hello',
},
].map(client => stores.clientApplicationsStore.upsert(client));
}
function createClientInstance(stores) {
return [
{
appName: 'demo-app-1',
instanceId: 'test-1',
strategies: ['default'],
started: Date.now(),
interval: 10,
},
{
appName: 'demo-seed-2',
instanceId: 'test-2',
strategies: ['default'],
started: Date.now(),
interval: 10,
},
].map(client => stores.clientInstanceStore.insert(client));
}
function createFeatures(stores) {
return [
{
name: 'featureX',
description: 'the #1 feature',
enabled: true,
strategies: [{ name: 'default', parameters: {} }],
},
{
name: 'featureY',
description: 'soon to be the #1 feature',
enabled: false,
strategies: [
{
name: 'baz',
parameters: {
foo: 'bar',
},
},
],
},
{
name: 'featureZ',
description: 'terrible feature',
enabled: true,
strategies: [
{
name: 'baz',
parameters: {
foo: 'rab',
},
},
],
},
{
name: 'featureArchivedX',
description: 'the #1 feature',
enabled: true,
archived: true,
strategies: [{ name: 'default', parameters: {} }],
},
{
name: 'featureArchivedY',
description: 'soon to be the #1 feature',
enabled: false,
archived: true,
strategies: [
{
name: 'baz',
parameters: {
foo: 'bar',
},
},
],
},
{
name: 'featureArchivedZ',
description: 'terrible feature',
enabled: true,
archived: true,
strategies: [
{
name: 'baz',
parameters: {
foo: 'rab',
},
},
],
},
].map(feature => stores.featureToggleStore._createFeature(feature));
}
function resetDatabase(stores) {
return Promise.all([
stores.db('strategies').del(),
stores.db('features').del(),
stores.db('client_applications').del(),
stores.db('client_instances').del(),
]);
}
function setupDatabase(stores) {
return Promise.all(
createStrategies(stores).concat(
createFeatures(stores)
.concat(createClientInstance(stores))
.concat(createApplications(stores))
)
);
}
module.exports = {
setupApp(name) {
return createApp(name).then(app =>
resetDatabase(app.stores)
.then(() => setupDatabase(app.stores))
.then(() => app)
);
async setupApp(name) {
const stores = await dbInit(name);
const app = createApp(stores);
return {
request: supertest.agent(app),
destroy: () => stores.db.destroy(),
};
},
async setupAppWithAuth(name) {
const stores = await dbInit(name);
const app = createApp(stores, 'unsecure');
return {
request: supertest.agent(app),
destroy: () => stores.db.destroy(),
};
},
async setupAppWithCustomAuth(name, preHook) {
const stores = await dbInit(name);
const app = createApp(stores, 'custom', preHook);
return {
request: supertest.agent(app),
destroy: () => stores.db.destroy(),
};
},
};

View File

@ -1,6 +1,12 @@
'use strict';
const _appliations = [];
module.exports = () => ({
upsert: () => Promise.resolve(),
getApplications: () => Promise.resolve([]),
upsert: app => {
_appliations.push(app);
return Promise.resolve();
},
getApplications: () => Promise.resolve(_appliations),
getApplication: appName => _appliations.filter(a => a.name === appName)[0],
});

167
yarn.lock
View File

@ -324,7 +324,7 @@ async@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/async/-/async-1.0.0.tgz#f8fc04ca3a13784ade9e1641af98578cfbd647a9"
async@~2.1.2:
async@~2.1.2, async@~2.1.4:
version "2.1.5"
resolved "https://registry.yarnpkg.com/async/-/async-2.1.5.tgz#e587c68580994ac67fc56ff86d3ac56bdbe810bc"
dependencies:
@ -756,6 +756,10 @@ balanced-match@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
base64url@2.0.0, base64url@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/base64url/-/base64url-2.0.0.tgz#eac16e03ea1438eff9423d69baa36262ed1f70bb"
bcrypt-pbkdf@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.1.tgz#63bc5dcb61331b92bc05fd528953c33462a06f8d"
@ -786,6 +790,10 @@ bluebird@^3.0.0, bluebird@^3.1.1, bluebird@^3.4.6:
version "3.5.1"
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.1.tgz#d9551f9de98f1fcda1e683d17ee91a0602ee2eb9"
blueimp-md5@^2.3.0:
version "2.10.0"
resolved "https://registry.yarnpkg.com/blueimp-md5/-/blueimp-md5-2.10.0.tgz#02f0843921f90dca14f5b8920a38593201d6964d"
body-parser@1.18.2, body-parser@^1.18.2:
version "1.18.2"
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.18.2.tgz#87678a19d84b47d859b83199bd59bce222b10454"
@ -850,6 +858,10 @@ buf-compare@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/buf-compare/-/buf-compare-1.0.1.tgz#fef28da8b8113a0a0db4430b0b6467b69730b34a"
buffer-equal-constant-time@1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819"
buffer-writer@1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/buffer-writer/-/buffer-writer-1.0.1.tgz#22a936901e3029afcd7547eb4487ceb697a3bf08"
@ -920,6 +932,10 @@ camelcase@^2.0.0:
version "2.1.1"
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-2.1.1.tgz#7c1d16d679a1bbe59ca02cacecfb011e201f5a1f"
camelcase@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-3.0.0.tgz#32fc4b9fcdaf845fcdf7e73bb97cac2261f0ab0a"
camelcase@^4.0.0, camelcase@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd"
@ -1552,6 +1568,13 @@ ecc-jsbn@~0.1.1:
dependencies:
jsbn "~0.1.0"
ecdsa-sig-formatter@1.0.9:
version "1.0.9"
resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.9.tgz#4bc926274ec3b5abb5016e7e1d60921ac262b2a1"
dependencies:
base64url "^2.0.0"
safe-buffer "^5.0.1"
ee-first@1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
@ -1564,6 +1587,10 @@ element-class@^0.2.0:
version "0.2.2"
resolved "https://registry.yarnpkg.com/element-class/-/element-class-0.2.2.tgz#9d3bbd0767f9013ef8e1c8ebe722c1402a60050e"
email-validator@^1.0.7:
version "1.1.1"
resolved "https://registry.yarnpkg.com/email-validator/-/email-validator-1.1.1.tgz#b07f3be7bac1dc099bc43e75f6ae399f552d5a80"
empower-core@^0.6.1:
version "0.6.2"
resolved "https://registry.yarnpkg.com/empower-core/-/empower-core-0.6.2.tgz#5adef566088e31fba80ba0a36df47d7094169144"
@ -2307,6 +2334,29 @@ globby@^6.0.0:
pify "^2.0.0"
pinkie-promise "^2.0.0"
google-auth-library@~0.10.0:
version "0.10.0"
resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-0.10.0.tgz#6e15babee85fd1dd14d8d128a295b6838d52136e"
dependencies:
gtoken "^1.2.1"
jws "^3.1.4"
lodash.noop "^3.0.1"
request "^2.74.0"
google-p12-pem@^0.1.0:
version "0.1.2"
resolved "https://registry.yarnpkg.com/google-p12-pem/-/google-p12-pem-0.1.2.tgz#33c46ab021aa734fa0332b3960a9a3ffcb2f3177"
dependencies:
node-forge "^0.7.1"
googleapis@^16.0.0:
version "16.1.0"
resolved "https://registry.yarnpkg.com/googleapis/-/googleapis-16.1.0.tgz#0f19f2d70572d918881a0f626e3b1a2fa8629576"
dependencies:
async "~2.1.4"
google-auth-library "~0.10.0"
string-template "~1.0.0"
got@^6.7.1:
version "6.7.1"
resolved "https://registry.yarnpkg.com/got/-/got-6.7.1.tgz#240cd05785a9a18e561dc1b44b41c763ef1e8db0"
@ -2327,6 +2377,24 @@ graceful-fs@^4.1.11, graceful-fs@^4.1.2:
version "4.1.11"
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.11.tgz#0e8bdfe4d1ddb8854d64e04ea7c00e2a026e5658"
gravatar@^1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/gravatar/-/gravatar-1.6.0.tgz#8bdc9b786ca725a8e7076416d1731f8d3331c976"
dependencies:
blueimp-md5 "^2.3.0"
email-validator "^1.0.7"
querystring "0.2.0"
yargs "^6.0.0"
gtoken@^1.2.1:
version "1.2.3"
resolved "https://registry.yarnpkg.com/gtoken/-/gtoken-1.2.3.tgz#5509571b8afd4322e124cf66cf68115284c476d8"
dependencies:
google-p12-pem "^0.1.0"
jws "^3.0.0"
mime "^1.4.1"
request "^2.72.0"
handlebars@^4.0.3:
version "4.0.11"
resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.0.11.tgz#630a35dfe0294bc281edae6ffc5d329fc7982dcc"
@ -3097,6 +3165,23 @@ jsprim@^1.2.2:
json-schema "0.2.3"
verror "1.10.0"
jwa@^1.1.4:
version "1.1.5"
resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.1.5.tgz#a0552ce0220742cd52e153774a32905c30e756e5"
dependencies:
base64url "2.0.0"
buffer-equal-constant-time "1.0.1"
ecdsa-sig-formatter "1.0.9"
safe-buffer "^5.0.1"
jws@^3.0.0, jws@^3.1.4:
version "3.1.4"
resolved "https://registry.yarnpkg.com/jws/-/jws-3.1.4.tgz#f9e8b9338e8a847277d6444b1464f61880e050a2"
dependencies:
base64url "^2.0.0"
jwa "^1.1.4"
safe-buffer "^5.0.1"
keygrip@~1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/keygrip/-/keygrip-1.0.2.tgz#ad3297c557069dea8bcfe7a4fa491b75c5ddeb91"
@ -3358,6 +3443,10 @@ lodash.merge@^4.6.0:
version "4.6.0"
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.0.tgz#69884ba144ac33fe699737a6086deffadd0f89c5"
lodash.noop@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/lodash.noop/-/lodash.noop-3.0.1.tgz#38188f4d650a3a474258439b96ec45b32617133c"
lodash@^4.0.0, lodash@^4.14.0, lodash@^4.15.0, lodash@^4.16.0, lodash@^4.17.4, lodash@^4.2.0, lodash@^4.2.1, lodash@^4.3.0, lodash@^4.6.0:
version "4.17.4"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.4.tgz#78203a4d1c328ae1d86dca6460e369b57f4055ae"
@ -3670,6 +3759,10 @@ node-fetch@^1.0.1:
encoding "^0.1.11"
is-stream "^1.0.1"
node-forge@^0.7.1:
version "0.7.1"
resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.7.1.tgz#9da611ea08982f4b94206b3beb4cc9665f20c300"
node-fs@~0.1.5:
version "0.1.7"
resolved "https://registry.yarnpkg.com/node-fs/-/node-fs-0.1.7.tgz#32323cccb46c9fbf0fc11812d45021cc31d325bb"
@ -3940,6 +4033,12 @@ os-homedir@^1.0.0, os-homedir@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3"
os-locale@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-1.4.0.tgz#20f9f17ae29ed345e8bde583b13d2009803c14d9"
dependencies:
lcid "^1.0.0"
os-locale@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-2.1.0.tgz#42bc2900a6b5b8bd17376c8e882b65afccf24bf2"
@ -4084,6 +4183,24 @@ parseurl@~1.3.2:
version "1.3.2"
resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.2.tgz#fc289d4ed8993119460c156253262cdc8de65bf3"
passport-google-auth@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/passport-google-auth/-/passport-google-auth-1.0.2.tgz#8b300b5aa442ef433de1d832ed3112877d0b2938"
dependencies:
googleapis "^16.0.0"
passport-strategy "1.x"
passport-strategy@1.x, passport-strategy@1.x.x:
version "1.0.0"
resolved "https://registry.yarnpkg.com/passport-strategy/-/passport-strategy-1.0.0.tgz#b5539aa8fc225a3d1ad179476ddf236b440f52e4"
passport@^0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/passport/-/passport-0.4.0.tgz#c5095691347bd5ad3b5e180238c3914d16f05811"
dependencies:
passport-strategy "1.x.x"
pause "0.0.1"
path-exists@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-2.1.0.tgz#0feb6c64f0fc518d9a754dd5efb62c7022761f4b"
@ -4144,6 +4261,10 @@ path-type@^2.0.0:
dependencies:
pify "^2.0.0"
pause@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/pause/-/pause-0.0.1.tgz#1d408b3fdb76923b9543d96fb4c9dfd535d9cb5d"
performance-now@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-0.2.0.tgz#33ef30c5c77d4ea21c5a53869d91b56d8f2555e5"
@ -4444,6 +4565,10 @@ query-string@^4.2.2:
object-assign "^4.1.0"
strict-uri-encode "^1.0.0"
querystring@0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620"
randomatic@^1.1.3:
version "1.1.7"
resolved "https://registry.yarnpkg.com/randomatic/-/randomatic-1.1.7.tgz#c7abe9cc8b87c0baa876b19fde83fd464797e38c"
@ -4805,7 +4930,7 @@ request@2.81.0:
tunnel-agent "^0.6.0"
uuid "^3.0.0"
request@^2.0.0, request@^2.74.0, request@^2.79.0:
request@^2.0.0, request@^2.72.0, request@^2.74.0, request@^2.79.0:
version "2.83.0"
resolved "https://registry.yarnpkg.com/request/-/request-2.83.0.tgz#ca0b65da02ed62935887808e6f510381034e3356"
dependencies:
@ -5275,6 +5400,10 @@ strict-uri-encode@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713"
string-template@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/string-template/-/string-template-1.0.0.tgz#9e9f2233dc00f218718ec379a28a5673ecca8b96"
string-width@^1.0.1, string-width@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3"
@ -5646,9 +5775,9 @@ unique-temp-dir@^1.0.0:
os-tmpdir "^1.0.1"
uid2 "0.0.3"
unleash-frontend@^3.0.0-alpha.4:
version "3.0.0-alpha.4"
resolved "https://registry.yarnpkg.com/unleash-frontend/-/unleash-frontend-3.0.0-alpha.4.tgz#d252c84fd6d9bd9972402013a6109f695207894d"
unleash-frontend@^3.0.0-alpha.5:
version "3.0.0-alpha.5"
resolved "https://registry.yarnpkg.com/unleash-frontend/-/unleash-frontend-3.0.0-alpha.5.tgz#ef4c2bb9e24ba07465b1737098b92b6a036df282"
dependencies:
debug "^3.1.0"
immutable "^3.8.1"
@ -5769,6 +5898,10 @@ when@~2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/when/-/when-2.0.1.tgz#8d872fe15e68424c91b4b724e848e0807dab6642"
which-module@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/which-module/-/which-module-1.0.0.tgz#bba63ca861948994ff307736089e3b96026c2a4f"
which-module@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a"
@ -5894,6 +6027,12 @@ yallist@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.0.2.tgz#8452b4bb7e83c7c188d8041c1a837c773d6d8bb9"
yargs-parser@^4.2.0:
version "4.2.1"
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-4.2.1.tgz#29cceac0dc4f03c6c87b4a9f217dd18c9f74871c"
dependencies:
camelcase "^3.0.0"
yargs-parser@^8.0.0:
version "8.0.0"
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-8.0.0.tgz#21d476330e5a82279a4b881345bf066102e219c6"
@ -5917,6 +6056,24 @@ yargs@^10.0.3:
y18n "^3.2.1"
yargs-parser "^8.0.0"
yargs@^6.0.0:
version "6.6.0"
resolved "https://registry.yarnpkg.com/yargs/-/yargs-6.6.0.tgz#782ec21ef403345f830a808ca3d513af56065208"
dependencies:
camelcase "^3.0.0"
cliui "^3.2.0"
decamelize "^1.1.1"
get-caller-file "^1.0.1"
os-locale "^1.4.0"
read-pkg-up "^1.0.1"
require-directory "^2.1.1"
require-main-filename "^1.0.1"
set-blocking "^2.0.0"
string-width "^1.0.2"
which-module "^1.0.0"
y18n "^3.2.1"
yargs-parser "^4.2.0"
yargs@~3.10.0:
version "3.10.0"
resolved "https://registry.yarnpkg.com/yargs/-/yargs-3.10.0.tgz#f7ee7bd857dd7c1d2d38c0e74efbd681d1431fd1"