Вступление

В последнее время я изучаю 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, который предоставляет наш пользовательский объект.

Схема для этого выглядит так:

  1. Отправлен GET запрос к /auth/github/callback
  2. passport.authenticate() называется
  3. passport.authenticate() вызывает наш strategyCallback() с поддельным профилем OAuth и анонимным done обратным вызовом
  4. strategyCallback() извлекает правильного пользователя из хранилища данных
  5. strategyCallback() вызывает обратный вызов done с объектом пользователя
  6. Обратный вызов done, предоставленный strategyCallback() в passport.authenticate(), вызывает this.success() с нашим пользовательским объектом
  7. 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) с рабочими тестами.

Удачного тестирования!