Вступление
В последнее время я изучаю OAuth, но как проводить функциональное тестирование? Здесь я продемонстрирую подход к тестированию приложения, использующего OAuth через популярное промежуточное ПО для паспортов для экспресс. Когда мы закончим, мы можем протестировать маршруты, требующие аутентификации, без каких-либо HTTPS-вызовов провайдеру OAuth.
Примечание: я использую Koa (и, следовательно, модуль-оболочку koa-паспорт), но этот подход будет работать с Express с модификацией.
Соответствующие файлы показаны ниже:
app.js index.js mockProfile.js package.json node_modules/: … test/: index.js util/: auth.js mock-strategy.js
Каждый файл служит определенной цели:
app.js
: оболочка для начальной загрузки приложенияindex.js
: ПриложениеmockProfile.js
: Мои данные профиля пользователя Github (замените здесь свои собственные данные профиля)package.json
: Наши зависимости и скриптыnode_modules/
: сторонние модулиtest/index.js
: Наши тестыutil/auth.js
: наш код аутентификацииutil/mock-strategy
: Наша ложная стратегия аутентификации паспорта
И наши зависимости:
"dependencies": { "koa": "^2.3.0", "koa-passport": "^3.0.0", "koa-route": "^3.2.0", "koa-session": "^5.4.0", "passport-github2": "^0.1.10" }, "devDependencies": { "supertest": "^3.0.0", "tape": "^4.8.0" }
В последующем обсуждении я для краткости опущу некоторый код, особенно код, имитирующий хранилище данных. Вызов userStore.fetchUser()
и userStore.saveUser()
ссылочных функций, определенных в util/auth.js
.
Наши маршруты
Маршруты для этого примера показаны ниже. Когда мы закончим, мы сможем получить доступ к /app
из наших тестов.
const route = require('koa-route'); app.use(route.get('/auth/github', passport.authenticate('github'); )); // Classic redirect behavior app.use(route.get('/auth/github/callback', passport.authenticate('github', { successRedirect: '/app', failureRedirect: '/' }) )); // Require authentication for now app.use(async function(ctx, next) { if (ctx.isAuthenticated()) { return next(); } else { ctx.throw(403); } }); app.use(route.get('/app', function(ctx) { ctx.body = ctx.state.user; }));
Выберите стратегию и предоставьте функцию обратного вызова
Внутриutil/auth.js
я определяю функцию, которая возвращает подходящую стратегию на основе переменной среды NODE_ENV
.
const passport = require(‘koa-passport’); const GitHubStrategy = require(‘passport-github2’).Strategy; const MockStrategy = require(‘./mock-strategy’).Strategy; function strategyForEnvironment() { let strategy; switch(process.env.NODE_ENV) { case ‘production’: strategy = new GitHubStrategy({ clientID: process.env.CLIENT_ID, clientSecret: process.env.CLIENT_SECRET, callbackURL: process.env.CALLBACK_URL }, strategyCallback); break; default: strategy = new MockStrategy(‘github’, strategyCallback); } return strategy; }
Показанная ниже функция обратного вызова strategyCallback()
- это то место, где мы получаем результат потока OAuth2. Обычно здесь вы сопоставляете идентификатор профиля OAuth с пользователем в своей базе данных или создаете его, если пользователя не существует. Эта функция является частью обычной конфигурации паспорта и передается как в GitHubStrategy
, так и в MockStrategy
.
function strategyCallback(accessToken, refreshToken, profile, done) { // Possibly User.findOrCreate({…}) or similar let u = { id: 1, oauthId: profile.id, oauthProvider: profile.provider, email: profile.emails[0].value, username: profile.username, avatarUrl: profile._json.avatar_url }; // synchronous in this example userStore.saveUser(u); done(null, u); }
В конце util/auth.js
мы выбираем желаемую стратегию, вызывая функцию strategyForEnvironment()
, которую мы определили ранее.
passport.use(strategyForEnvironment());
Определите фиктивную стратегию
Мок-стратегия определяется с использованием интерфейса стратегии, предоставляемого паспортом. Этот код находится в util/mock-strategy.js
:
// https://github.com/marcosnils/passport-dev/blob/master/lib/strategy.js const passport = require(‘passport-strategy’); const util = require(‘util’); // The reply from Github OAuth2 const user = require(‘../mockProfile’); function Strategy(name, strategyCallback) { if (!name || name.length === 0) { throw new TypeError(‘DevStrategy requires a Strategy name’) ; } passport.Strategy.call(this); this.name = name; this._user = user; // Callback supplied to OAuth2 strategies handling verification this._cb = strategyCallback; } util.inherits(Strategy, passport.Strategy); Strategy.prototype.authenticate = function() { this._cb(null, null, this._user, (error, user) => { this.success(user); }); } module.exports = { Strategy };
Приведенный выше код имеет две важные цели: он определяет функцию-конструктор для нашей фиктивной стратегии, которая принимает нашу strategyCallback()
функцию, и определяет метод passport.authenticate()
, используемый в нашем маршруте для /auth/github/callback
, который предоставляет наш пользовательский объект.
Схема для этого выглядит так:
- Отправлен
GET
запрос к/auth/github/callback
passport.authenticate()
называетсяpassport.authenticate()
вызывает нашstrategyCallback()
с поддельным профилем OAuth и анонимнымdone
обратным вызовомstrategyCallback()
извлекает правильного пользователя из хранилища данныхstrategyCallback()
вызывает обратный вызовdone
с объектом пользователя- Обратный вызов
done
, предоставленныйstrategyCallback()
вpassport.authenticate()
, вызываетthis.success()
с нашим пользовательским объектом this.success()
выполняет необходимую работу для заполнения пользовательского сеанса
Наша strategyCallback()
функция, определенная ранее в util/auth.js
, сохраняется в this._cb
в Strategy()
функции-конструкторе.
Метод passport.authenticate()
вызывается нашей функцией обработки маршрута для маршрута /auth/github/callback
. Здесь мы вызываем this._cb
(содержащий ссылку на наш strategyCallback()
). Как вы помните, сигнатура функции - function strategyCallback(accessToken, refreshToken, profile, done)
. В нашем макете нас не интересуют accessToken
или refreshToken
, поэтому мы передаем null
для обоих.
Мы действительно заботимся о profile
и done
. Мы имитируем объект profile
, в этом примере используя мои собственные данные профиля Github. Мы предоставляем функцию обратного вызова для done
, в которой мы вызываем this.success(user)
. Наш done
обратный вызов - это то, как strategyCallback()
предоставляет наш конечный пользовательский объект, тот, который сериализуется в ctx.state.user
в нашем примере приложения Koa.
Ого, есть над чем подумать!
Тест с сессиями
Теперь мы можем тестировать маршруты приложений, требующие аутентификации! Давайте определим несколько вспомогательных методов для наших тестов. Первая вспомогательная функция подключает сервер и настраивает supertest
для работы с ним. Мы можем использовать эту функцию для настройки нового экземпляра сервера для каждого теста.
Я использую tape
в качестве тестовой среды, которая требует, чтобы все оставшиеся дескрипторы файлов были явно закрыты. Это цель вызовов httpServer.close()
, которые показаны в примерах ниже.
const prepare = () => { const httpServer = app.listen(); const request = agent(app.callback()); return { request, httpServer }; }
Следующий помощник, createAuthenticatedUser()
, позволяет нам связывать запросы с помощью supertest
. Он работает, сохраняя объект запроса в authenticatedUser
, который сохраняет состояние нашего запроса для будущего использования. Во-первых, функция аутентифицирует пользователя, обращаясь к маршруту /auth/github/callback
, определенному ранее. Затем он передает authenticatedUser
функции обратного вызова, которую мы предоставляем для выполнения будущих запросов.
// https://medium.com/@juha.a.hytonen/testing-authenticated-requests-with-supertest-325ccf47c2bb const createAuthenticatedUser = (done) => { const httpServer = app.listen(); const authenticatedUser = agent(app.callback()); authenticatedUser .get(‘/auth/github/callback’) .end((error, resp) => { done(authenticatedUser); httpServer.close(); }); }
Теперь вы можете тестировать маршруты, требующие аутентификации. В приведенном ниже тесте вызывается createAuthenticatedUser
и передается функция обратного вызова, которая получает постоянный объект supertest
в качестве единственного параметра. Выполнение запроса с использованием этого объекта позволяет выполнить запрос.
test(‘it should work’, t => { createAuthenticatedUser(request => { request .get(‘/app’) .expect(200) .end((err, res) => { console.log(res.body); t.end(err); }); }); });
Тот же запрос без аутентифицированного объекта запроса завершится ошибкой:
test(‘it should deny access to /app’, t => { const {httpServer, request} = prepare(); request .get(‘/app’) .expect(403) .end((err, res) => { httpServer.close(); t.end(err); }); });
Доступен весь репозиторий (koa-паспорт-oauth2-testing) с рабочими тестами.
Удачного тестирования!