[RUBY] I implemented Rails API with TDD by RSpec. part2 -user authentication-

at first

This article I implemented Rails API with TDD by RSpec. part1 Part 2 of this article. Please see from part1 if you like. The goal this time is to be able to handle the login function and logout function of User authentication using octokit. This article is quite long. There are many parts that are difficult to understand if it is a fragmentary code only for articles, so please read your own code appropriately and understand the contents. Also, if you have any expressions that are difficult to understand, please comment. Then I will go for the first time.

Communication with Github API

Register on Github

First of all, you need to register the application on github in order to communicate using Api on Github. https://github.com/settings/apps Jump to this page and go to register from the New Github App.

The registration items are as follows.

Application name: -> Unique and freely name your application

Homepage URL: -> http://localhost:3000 Register the url for development.

Application description: -> Enter explanations for easy understanding

Authorization callback URL: -> http://localhost:3000/oauth/github/callback Setting URL for redirect

Press Register Application when you are done. Then a display like that is returned.

Owned by: @user_name

App ID: xxxxx

Client ID: Iv1.xxxxxxxxxxxxxxxxxxxxx

Client secret: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Use this ClientID and ClientSecrete to connect to the gitub API. Copy it somewhere.

octokit

Next, we will introduce a gem called octokit.

official https://github.com/octokit/octokit.rb

By using octokit, it seems that it is easier to link with github. (I don't really know what's going on inside)

And since I have already added the octokit gem at the beginning, I will continue as it is.

Move to the terminal.

$ GITHUB_LOGIN='githubuser_name' GITHUB_PASSWORD='github_password' rails c

First, put the two values in the environment variables. This is the username and password you normally use to log in to github. Then make sure the console opens. For the time being ENV['GITHUB_LOGIN'] Make sure that the contents are included by hitting.

$ client = Octokit::Client.new(login: ENV['GITHUB_LOGIN'], password: ENV['GITHUB_PASSWORD'])
$ client.user

Then connect to octokit and make sure that the user information is properly collected.

This is just an exercise. In the future, we will implement it using this mechanism.

User.rb generation

Now, let's create a User model.

$ rails g model login name url avatar_url provider

Add database-level restrictions to migration files.

xxxxxxxxx_create_users.rb


class CreateUsers < ActiveRecord::Migration[6.0]
  def change
    create_table :users do |t|
      t.string :login, null: false
      t.string :name
      t.string :url
      t.string :avatar_url
      t.string :provider

      t.timestamps
    end
  end
end

Since the file has been generated, add null: false to the login attribute.

$ rails db:migrate

Validation test

Next, we will add restrictions at the model level. I would like to add validation, but first I will write from the test.

spec/models/user_spec.rb


require 'rails_helper'

RSpec.describe User, type: :model do
  describe '#validations' do

    it 'should have valid factory' do
      user = build :user
      expect(user).to be_valid
    end

    it 'should validate presence of attributes' do
      user = build :user, login: nil, provider: nil
      expect(user).not_to be_valid
      expect(user.errors.messages[:login]).to include("can't be blank")
      expect(user.errors.messages[:provider]).to include("can't be blank")
    end

    it 'should validate uniqueness of login' do
      user = create :user
      other_user = build :user, login: user.login
      expect(other_user).not_to be_valid
      other_user.login = 'newlogin'
      expect(other_user).to be_valid
    end
  end
end

The first test is a test to see if the factorybot is running The second is a test to check if login and provider are included. The third is a test to see if login is unique

Also, if factorybot is at the moment, the same user will be added no matter how many times it is created, so fix that.

spec/factories/user.rb


FactoryBot.define do
  factory :user do
    sequence(:login) { |n| "a.levine #{n}" }
    name { "Adam Levine" }
    url { "http://example.com" }
    avatar_url { "http://example.com/avatar" }
    provider { "github" }
  end
end

Solve using sequence. This makes the login of the user created each time unique.

Now run the test.

$ rspec spec/models/user_spec.rb

At this point, check that there is no typo and the error is occurring normally. The test to see if the first factorybot is working properly is successful.

validation implementation

We will implement validation from now on.

models/user.rb


class User < ApplicationRecord
  validates :login, presence: true, uniqueness: true
  validates :provider, presence: true
end

Run the test to make sure it succeeds.

Next, write the code to interact with github.

Create UserAuthenticator.rb

ʻCreate app / lib directory Under that, create ʻapp / lib / user_authenticator.rb.

app/lib/user_authenticator.rb


class UserAuthenticator
  def initialize
  end
end

Originally, TDD is the one to write the test code first, but if you define the class first, the correct error will be thrown out, so it is faster to create the file and define the class first.

Test if code is incorrect

Then write the test. Create a lib directory and files. spec/lib/user_authenticator_spec.rb

spec/lib/user_authenticator_spec.rb


require 'rails_helper'

describe UserAuthenticator do
  describe '#perform' do
    context 'when code is incorrenct' do
      it 'should raise an error' do
        authenticator = described_class.new('sample_code')
        expect{ authenticator.perform }.to raise_error(
          UserAuthenticator::AuthenticationError
        )
        expect(authenticator.user).to be_nil
      end
    end
  end
end

This time, we will use an instance method called perform to sign in and log in.

First, when the code is inappropriate. (By the way, code is a one-time token issued by github, and this time we will not actually receive that code, so code uses just a string and how github behaves for that code. By using a mock for the part, I try to complete the test without the code actually issued. The code is used to exchange for a token unique to github user.)

Create an instance with described_class.new and execute the method with authenticator.perform. ʻUserAuthenticator :: AuthenticationError` is defined in its own class.

When I run the test, it says that there is no .perform. And it is said that .user cannot be used.

So I will actually write it.

user_authentiator # perform implementation

app/lib/user_authenticator.rb


class UserAuthenticator
  class AuthenticationError < StandardError; end

  attr_reader :user

  def initialize(code)

  end

  def perform
    raise AuthenticationError
  end
end

Make it possible to read user at any time with attr_readerd. And perform is also defined. Define ʻAuthenticationErrorthat inheritsStandardError and nest it in ʻUserAuthenticator. The reason why I raise it in perform is to make the test successful for the time being.

Now when I run the test it succeeds. $ rspec spec/lib/user_authenticator_spec.rb

Test if code is correct

And next, write a test when the code is correct. But before that, I used it in should raise an error

authenticator = described_class.new('sample_code') authenticator.perform

These two parts

spec/lib/user_authenticator_spec.rb


  describe '#perform' do
    let(:authenticator) { described_class.new('sample_code') }
    subject { authenticator.perform }

Define it like this and use it in the when code is correct that I will write.

So the whole picture now is as follows.

spec/lib/user_authenticator_spec.rb


  describe '#perform' do
    let(:authenticator) { described_class.new('sample_code') }
    subject { authenticator.perform }
    context 'when code is incorrenct' do
      it 'should raise an error' do
        expect{ subject }.to raise_error(
          UserAuthenticator::AuthenticationError
        )
        expect(authenticator.user).to be_nil
      end
    end
  end

Then write a test when the code is correct

spec/lib/user_authenticator_spec.rb


    context 'when code is correct' do
      it 'should save the user when does not exists' do
        expect{ subject }.to change{ User.count }.by(1)
      end
    end

If user is a user that does not exist in the database in advance, User.count is incremented by 1. This is a new registration of user.

Now I run the test but of course it fails. That's because the perform action says raise Authentication Error no matter what. So, we will implement the perform method.

Description of the execution part

app/lib/user_authenticator.rb


  def perform
    client = Octokit::Client.new(
      client_id: ENV['GITHUB_CILENT_ID'],
      client_secret: ENV['GITHUB_CILENT_SECRET'],
    )
    res = client.exchange_code_for_token(code)
    if res.error.present?
      raise AuthenticationError
    else

    end
  end

What we're doing here is to have github authenticate the project at the beginning of the article. Put the two values that client_id and client_secret were displayed when you registered this project on github at the beginning of this article in this environment variable. But this time, the actual value is not used. For the time being, I will explain it later.

client.exchange_code_for_token(code) This part remains as it is, but the code is exchanged for token. The token is only temporary generated by the github API as described above.

And if the returned response is an error, it can be retrieved with res.error, so the error is raised only when an error is included.

Now run the test once.

404 - Error: Not Found

Probably 404 is spit out. This is because the contents of GITHUB_CILENT_ID and GITHUB_CILENT_SECRET are empty. However, since this is a test, we cannot enter the true value here. Ideally, the test should be completed only by the test, eliminating the network environment as much as possible.

mock implementation

So I use a mock for testing. A mock is for creating an alternative to github communication on this side and completing it in a test.

spec/lib/user_authenticator_spec.rb


    context 'when code is incorrenct' do
      before do
        allow_any_instance_of(Octokit::Client).to receive(
          :exchange_code_for_token).and_return(error)
      end

Therefore, before is used like this, and a method called allow_any_instance_of is used.

allow_any_instance_of(Instance name).to receive(:Method name).and_return(Return value)

Use it like this. You can use this to specify the return value when the specified method of the specified instance is called.

An error is returned when calling the exchange_code_for_token method from an instance of Octokit :: Client.

Define the error of the return value.

spec/lib/user_authenticator_spec.rb


    context 'when code is incorrenct' do
      let(:error) {
        double("Sawyer::Resource", error: "bad_verification_code")
      }

double is the method for creating a mock. Sawyer :: Resource is a class name and you can use error as a method of that class. The actual error can be faithfully reproduced.

Now when I run the test, the first succeeds, but the other fails. It's 404, so it's the same as before.

The second test is defined in the same way as the previous mock.

spec/lib/user_authenticator_spec.rb


    context 'when code is correct' do
      before do
        allow_any_instance_of(Octokit::Client).to receive(
          :exchange_code_for_token).and_return('validaccesstoken')
      end

But this time, instead of issuing an error, it returns a valid access token. It's not really a meaningful string, but in the sense that it's not an error, this value still works as a good token for testing.

Run the test.

undefined method `error' for "validaccesstoken":String

Message appears.

this is

app/lib/user_authenticator.rb


    if res.error.present?

Regarding this part, an error occurred because I was trying to read the error even when there was no error in res. So, if there is no error, write to return nil.

app/lib/user_authenticator.rb


    if res.try(:error).present?

Now run the test.

expected User.count to have changed by 1, but was changed by 0

It can be said that it is a normal message because the operation to save has not been written yet. So, I will write the process of saving the data.

#perform save processing implementation

app/lib/user_authenticator.rb


    client = Octokit::Client.new(
      client_id: ENV['GITHUB_CILENT_ID'],
      client_secret: ENV['GITHUB_CILENT_SECRET'],
    )
    token = client.exchange_code_for_token(code)
    if token.try(:error).present?
      raise AuthenticationError
    else
      user_client = Octokit::Client.new(
        access_token: token
      )
      user_data = user_client.user.to_h
        slice(:login, :avatar_url, :url, :name)
      User.create(user_data.merge(provider: 'github'))
    end

Rewrite like this. Create an instance of github user using the token returned in exchange for code.


user_client = Octokit::Client.new(
        access_token: token
      )

This part of the above does the same thing as creating an instance using login and password. The same result is output regardless of whether token is used or login and password are used.

//It's just a sample so you don't have to actually hit it
$ client = Octokit::Client.new(login: ENV['GITHUB_LOGIN'], password: ENV['GITHUB_PASSWORD'])
$ client.user

Earlier in this article, I typed a command like this on the console, and it does exactly the same thing. You can get the data of github user by actually doing client.user. However, the format is Sawyer :: Resource, which is very difficult to handle. So, once converted to hash with to_h, the contents are taken out with the slice method. And it is saved in the database as it is using the create method. The provider is merged because the provider is not in the retrieved data, so you need to add it yourself. If you don't, you will get stuck in validation.

Incidentally, I changed res to token. It is preferable to use the variable name as what it actually means in terms of logic.

Then run the test.

401 - Bad credentials

Next, such a message changes. 401 seems to be an error returned when you can not log in etc. However, this time it's just an instance made with a mock, so you don't have to be able to actually authenticate.

app/lib/user_authenticator.rb


      user_data = user_client.user.to_h.
        slice(:login, :avatar_url, :url, :name)

Currently there is an error in this user_client.user part. So, how to return when user_client.user is done is reproduced by mock.

spec/lib/user_authenticator_spec.rb


        allow_any_instance_of(Octokit::Client).to receive(
          :exchange_code_for_token).and_return('validaccesstoken')
        allow_any_instance_of(Octokit::Client).to receive(
          :user).and_return(user_data)
      end

I added: user. Then add the variable user_data.

spec/lib/user_authenticator_spec.rb


    context 'when code is correct' do
      let(:user_data) do
        {
          login: 'a.levine 1',
          url: 'http://example.com',
          avatar_url: 'http://example.com/avatar',
          name: 'Adam Levine'
        }
      end

Now the test runs and succeeds.

Also, make sure that the stored values are correct.

spec/lib/user_authenticator_spec.rb


        allow_any_instance_of(Octokit::Client).to receive(
          :exchange_code_for_token).and_return('validaccesstoken')
        allow_any_instance_of(Octokit::Client).to receive(
          :user).and_return(user_data)
      end
      it 'should save the user when does not exists' do
        expect{ subject }.to change{ User.count }.by(1)
        expect(User.last.name).to eq('Adam Levine')
      end

Add the bottom line.

Now run the test and make sure it passes.

However, although a new user is created every time, I want to reuse the user once created. Obviously, it's like making a new registration every time, so it's inefficient. So I will write the code so that it can be used.

Reuse the user once saved

First of all, I will write from the test.

spec/lib/user_authenticator_spec.rb


      it 'should reuse already registerd user' do
        user = create :user, user_data
        expect{ subject }.not_to change{ User.count }
        expect(authenticator.user).to eq(user)
      end

Create user once and use the same user_data to do authenticator.perform. Then, check if the user created by authenticator.perform and the user created by factorybot are the same.

Run the test to make sure it fails. Right now, I'm not reusing it yet, but creating it every time. So, I will describe it so that it can be used.

app/lib/user_authenticator.rb


-      User.create(user_data.merge(provider: 'github'))
+      @user = if User.exists?(login: user_data[:login])
+        User.find_by(login: user_data[:login])
+      else
+        User.create(user_data.merge(provider: 'github'))
+      end

Rewrite like this. If the same user exists, create a branch that uses find_by.

Running the test succeeds.

Refactoring

However, at this point, the amount of description of perform methods is too large, and the responsibility of perform methods is ambiguous. Since the perform method has the meaning of so-called execution, it is preferable that the method is only for executing. So, write the logic that generates and arranges the value to another method.

app/lib/user_authenticator.rb


  def perform
-    client = Octokit::Client.new(
-      client_id: ENV['GITHUB_CILENT_ID'],
-      client_secret: ENV['GITHUB_CILENT_SECRET'],
-    )
-    token = client.exchange_code_for_token(code)
    if token.try(:error).present?
      raise AuthenticationError
    else
-     user_client = Octokit::Client.new(
-        access_token: token
-      )
-      user_data = user_client.user.to_h.
-        slice(:login, :avatar_url, :url, :name)
-      @user = if User.exists?(login: user_data[:login])
-        User.find_by(login: user_data[:login])
-      else
-        User.create(user_data.merge(provider: 'github'))
-      end
+      prepare_user
    end

Roughly delete this part and move it to another place. The location to move is defined by the private method. The reason is that it defines a value that does not need to be called from an external class.

app/lib/user_authenticator.rb


  private

+  def client
+    @client ||= Octokit::Client.new(
+      client_id: ENV['GITHUB_CILENT_ID'],
+      client_secret: ENV['GITHUB_CILENT_SECRET'],
+    )
+  end
+
+  def token
+    @token ||= client.exchange_code_for_token(code)
+  end
+
+  def user_data
+    @user_data ||= Octokit::Client.new(
+      access_token: token
+    ).user.to_h.slice(:login, :avatar_url, :url, :name)
+  end
+
+  def prepare_user
+    @user = if User.exists?(login: user_data[:login])
+      User.find_by(login: user_data[:login])
+    else
+      User.create(user_data.merge(provider: 'github'))
+    end
+  end

  attr_reader :code
end

Write it like this. The structure is such that the lower method calls the upper method, and the responsibilities are separated neatly.

Now run the test to make sure it doesn't fail.

This is the end of refactoring.

Next.

Generate token for User authentication

Next, I will make an access_token for railsapi that I am making now. The token obtained using the exchange_code_for_token method is just a token for accessing the github API and getting user information, so it cannot be used to authenticate the rails API request we are making.

From now on, I will make a token for request authentication of the rails API that I am making now. This token is needed when performing a create action or a delete action. On the contrary, when performing index action or show action, accept the request even if there is no token. But that depends on the application.

Token generation test

Then I will make the token, but first I will write it from the test.

spec/lib/user_authenticator_spec.rb


      it "should create and set user's access token" do
        expect{ subject }.to change{ AccessToken.count }.by(1)
        expect(authenticator.access_token).to be_present
      end

Added this test at the end.

Then, after that, edit the perform method.

app/lib/user_authenticator.rb


     else
       prepare_user
+      @access_token = if user.access_token.present?
+                 user.access_token
+               else
+                 user.create_access_token
+               end
     end

In this way, token is set as the attribute of the instance.

app/lib/user_authenticator.rb


attr_reader :user, :access_token

In addition, make it possible to call access_token. For the time being, the explanation will be explained in detail later.

AccessToken model generation

$ rails g model access_token token user:references

For the time being, create an access_token model. This will create an access_token model with belongs_to: user.

Set the association for the user model as well.

app/models/user.rb


class User < ApplicationRecord
  validates :login, presence: true, uniqueness: true
  validates :provider, presence: true

  has_one :access_token, dependent: :destroy #add to
end

db/migrate/xxxxxxxxx_create_access_tokne.rb


class CreateAccessTokens < ActiveRecord::Migration[6.0]
  def change
    create_table :access_tokens do |t|
      t.string :token, null: false
      t.references :user, null: false, foreign_key: true

      t.timestamps
    end
  end
end

Also check the migration file, add nill: false to token.

Execute rails db: migrate.

Next, prepare an access token test.

spec/models/access_token_spec.rb


require 'rails_helper'

RSpec.describe AccessToken, type: :model do
  describe '#validations' do
    it 'should have valid factory' do

    end

    it 'should validate token' do

    end
  end
end

Now that everything is ready, run the test. $ rspec spec/lib/user_authenticator_spec.rb

SQLite3::ConstraintException: NOT NULL constraint failed: access_tokens.token

Then, such a message is spit out. This error seems to occur if you have null: false at the database level, but it is null.

Then write the logic to generate the token so that it will not be null. Write a test before that.

spec/models/access_token_spec.rb


  describe '#new' do
    it 'should have a token present after initialize' do
      expect(AccessToken.new.token).to be_present
    end

    it 'should generate uniq token' do
      user = create :user
      expect{ user.create_access_token }.to change{ AccessToken.count }.by(1)
      expect(user.build_access_token).to be_valid
    end
  end

Add this code to the end.

The first is whether the token is properly included when the AccessToken is renewed. I will write it later, but I will write it later so that the token will be automatically entered when new.

The second is whether the AccessToken count will increase by 1. Is it not caught in validation? Whether or not it does not hit validation, usually I create a model, use the first value for the second, build, and check if it hits validation properly, but this time a little Since the token is automatically generated when you make a special new, you cannot test it. Because you can't specify an argument like AccessToken.new (old_token). If you use AccessToken.new, the token is automatic.

token generation logic implementation

Now let's write the logic to generate the token.

app/models/access_token.rb


class AccessToken < ApplicationRecord
  belongs_to :user

  after_initialize :generate_token

  private

  def generate_token
    loop do
      break if token.present? && !AccessToken.exists?(token: token)
      self.token = SecureRandom.hex(10)
    end
  end
end

The method specified by after_inialize is executed when the model is created.

I'm turning it in a loop because I want to create a token as many times as I want unless the conditions specified by break if are met. Generate a token using the SecureRandom class. The values are created randomly, so the exact same values may not be generated. So let's loop. The break condition has a value in token. And the same value does not exist in the database. Loop as many times as you like unless that is the case. Usually it breaks once it turns.

Run the test. $ rspec spec/models/access_token_spec.rb $ rspec spec/lib/user_authenticator_spec.rb

Make sure this test passes.

By the way, ʻuser.create_access_tokenin user_authenticator.rb This method is not defined somewhere, it is automatically generated by rails. The meaning remains the same, but if you replace it in an easy-to-understand manner,AccessToken.create(user_id: user.id)` It has the same meaning as this.

Now that the logic for token generation is over, let's move on.

Login function

Next, we will implement the overall picture of the login function. Currently, a mechanism to generate a token has been established, but a login function using that token has not yet been implemented. So I will implement that area.

Endpoint testing

But first write from the test. I haven't done routing yet, so I'll start with the routing test. There is no file to describe, so create it.

spec/routing/access_token_spec.rb


require 'rails_helper'

describe 'access tokens routes' do
  it 'should route to access_tokens create action' do
    expect(post '/login').to route_to('access_tokens#create')
  end
end

The explanation of the description is omitted.

When I run the test, it says no route match / login, so edit routes.rb.

config/routes.rb


Rails.application.routes.draw do
+  post 'login', to: 'access_tokens#create'
  resources :articles, only: [:index, :show]
end

Test run.

A route matches "/login", but references missing controller: AccessTokensController

It is said that there is no controller, so I will make one.

Access_tokens_controller generation

$ rails g controller access_tokens

      create  app/controllers/access_tokens_controller.rb
      invoke  rspec
      create    spec/requests/access_tokens_request_spec.rb

Run the test again. The test passes. This completes the login endpoint installation.

Testing access_tokens_controller

Now let's test the controller. Create and describe the following file.

spec/controllers/access_tokens_controller_spec.rb


require 'rails_helper'

RSpec.describe AccessTokensController, type: :controller do
  describe '#create' do
    context 'when invalid request' do
      it 'should return 401 status code' do
        post :create
        expect(response).to have_http_status(401)
      end
    end

    context 'when success request' do

    end
  end
end

I expect 401 to come back without authentication. 401 is unauthorized, but it is semantically unauthenticated, so it is often used as a response when it is not authenticated.

Although it is still new, when rails g controller is used, files such as requests / access_tokens_request_spec.rb are automatically generated. This is the successor to the controller test, but the way it is written is slightly different from controller_spec, so this time I purposely created and described the file myself. Originally, it is recommended to write in request_spec.

Run the test.

AbstractController::ActionNotFound: The action 'create' could not be found for AccessTokensController

Since the create action is not defined, write it.

app/controllers/access_tokens_controller.rb


class AccessTokensController < ApplicationController
  def create

  end
end

Run the test. I'm expecting 401, but 204 is back. 204 means: no_content.

So for the time being, I will write it in the controller to pass the test.

create implementation

app/controllers/access_tokens_controller.rb


class AccessTokensController < ApplicationController
  def create
    render json: {}, status: 401
  end
end

Run the test to make sure it passes.

I will add more tests.

spec/controllers/access_token_controller_spec.rb


    context 'when invalid request' do
+      let(:error) do
+        {
+          "status" => "401",
+          "source" => { "pointer" => "/code" },
+          "title" =>  "Authentication code is invalid",
+          "detail" => "You must privide valid code in order to exchange it for token."
+        }
+      end
      it 'should return 401 status code' do
        post :create
        expect(response).to have_http_status(401)
      end

+      it 'should return proper error body' do
+        post :create
+        expect(json['errors']).to include(error)
+      end
    end

Expect the correct error res to be returned in the case of 401. The error statement is edited and used by copying it from the following site. https://jsonapi.org/examples/

Then run the test.

expected: {"detail"=>"You must privide valid code in order to exchange it for token.", "source"=>{"pointer"=>"/code"}, "status"=>"401", "title"=>"Authentication code is invalid"}
got: nil

Since nil is returned, write a process that returns an error properly on the controlle side.

app/controllers/access_tokens_controller.rb


class AccessTokensController < ApplicationController
  def create
    error = {
      "status" => "401",
      "source" => { "pointer" => "/code" },
      "title" =>  "Authentication code is invalid",
      "detail" => "You must privide valid code in order to exchange it for token."
    }
    render json: { "errors": [ error ] }, status: 401
  end
end

Make sure this passes the test.

Currently, when the create action is called, it gives an error in everything, but fix it.

app/controllers/access_tokens_controller.rb


class AccessTokensController < ApplicationController
  rescue_from UserAuthenticator::AuthenticationError, with: :authentication_error

  def create
    authenticator = UserAuthenticator.new(params[:code])
    authenticator.perform
  end

  private

  def authentication_error
    error = {
      "status" => "401",
      "source" => { "pointer" => "/code" },
      "title" =>  "Authentication code is invalid",
      "detail" => "You must privide valid code in order to exchange it for token."
    }
  end

I'm editing the code for refactoring as well. At this point, we finally write ʻUserAuthenticator.new (params [: code])`. The logic to create a user by exchanging code and token, which I have written all the time, is written in UserAuthenticator, but I call it here.

Then execute it with perform.

The body of the 401 error is written to the method. The error returned at this point is ʻUserAuthenticator :: AuthenticationError`, so rescue_from will be used to rescue. Since it is written to the method, it can be called with rescue_from.

After that, in UserAuthenticator :: AuthenticationError, I want to issue the same error even when the code is blank. By the way, I need to refactor.

Refactoring and fixing

app/lib/user_authenticator.rb


  def perform
    raise AuthenticationError if code.blank? || token.try(:error).present?
    prepare_user
    @access_token = if user.access_token.present?
               user.access_token
             else
               user.create_access_token
             end
  end

Now you can get an error when the code is blank.

To recap, code is the token sent from the front end. The front end gets the token from github and sends it to the api. That is code (github_access_code). The API receives the code and communicates with GitHub to exchange the code for token (by the exchange_code_for_token method). With that token, github user information can be obtained from the github API.

Based on that, it is possible that the code is sufficiently blank, so prepare an error.

Run the test to make sure it passes.

Further refactor.

app/controlers/access_token_controller.rb


class AccessTokensController < ApplicationController
-  rescue_from UserAuthenticator::AuthenticationError, with: :authentication_error

  def create
    authenticator = UserAuthenticator.new(params[:code])
    authenticator.perform
  end

-  private
-
-  def authentication_error
-    error = {
-      "status" => "401",
-      "source" => { "pointer" => "/code" },
-      "title" =>  "Authentication code is invalid",
-      "detail" => "You must privide valid code in order to exchange it for token."
-    }
-    render json: { "errors": [ error ] }, status: 401
-  end
end

app/controllers/application_controller.rb


class ApplicationController < ActionController::API
+  rescue_from UserAuthenticator::AuthenticationError, with: :authentication_error

+  private

+  def authentication_error
+    error = {
+      "status" => "401",
+      "source" => { "pointer" => "/code" },
+      "title" =>  "Authentication code is invalid",
+      "detail" => "You must privide valid code in order to exchange it for token."
+    }
+    render json: { "errors": [ error ] }, status: 401
+  end
end

Completely leave authentication_error to application_controller so that all controllers can pick up this error. The reason is that authentication errors can occur on any controller.

Run the test to make sure nothing has changed.

And it's even better to use this implementation in tests as well. The code will paste all the changes for now as the explanation will be lengthy

spec/controllers/access_token_controller_spec.rb


RSpec.describe AccessTokensController, type: :controller do
  describe '#create' do
-    context 'when invalid request' do
+    shared_examples_for "unauthorized_requests" do
      let(:error) do
        {
          "status" => "401",
@ -11,17 +11,34 @@ RSpec.describe AccessTokensController, type: :controller do
          "detail" => "You must privide valid code in order to exchange it for token."
        }
      end

      it 'should return 401 status code' do
-        post :create
+        subject
        expect(response).to have_http_status(401)
      end

      it 'should return proper error body' do
-        post :create
+        subject
        expect(json['errors']).to include(error)
      end
    end

+    context 'when no code privided' do
+      subject { post :create }
+      it_behaves_like "unauthorized_requests"
+    end
+    context 'when invalid code privided' do
+      let(:github_error) {
+        double("Sawyer::Resource", error: "bad_verification_code")
+      }
+      before do
+        allow_any_instance_of(Octokit::Client).to receive(
+          :exchange_code_for_token).and_return(github_error)
+      end
+      subject { post :create, params: { code: 'invalid_code' } }
+      it_behaves_like "unauthorized_requests"
+    end

    context 'when success request' do

    end

I'd like you to read the code carefully to see what you're doing, but here we're using two tests with shared_examples_for. should return 401 status code should return proper error body

These two tests will often be reused in the future. You can also call shared_examples_for using it_behaves_like. By using subject and making it DRY, you can freely enter a value for each context in subject.

spec/controllers/access_token_controller_spec.rb


      let(:github_error) {
        double("Sawyer::Resource", error: "bad_verification_code")
      }
      before do
        allow_any_instance_of(Octokit::Client).to receive(
          :exchange_code_for_token).and_return(github_error)
      end

Also, regarding this part, this description was used in the test before, and it is reproduced with mock without connecting directly to the github API. This allows you to reproduce the github API without actually connecting to github.

Next, I will write a test when the code is correct.

spec/controllers/access_token_controller_spec.rb


    context 'when success request' do
      let(:user_data) do
        {
          login: 'a.levine 1',
          url: 'http://example.com',
          avatar_url: 'http://example.com/avatar',
          name: 'Adam Levine'
        }
      end

      before do
        allow_any_instance_of(Octokit::Client).to receive(
          :exchange_code_for_token).and_return('validaccesstoken')
        allow_any_instance_of(Octokit::Client).to receive(
          :user).and_return(user_data)
      end

      subject { post :create, params: { code: 'valid_code' } }
      it 'should return 201 status code' do
        subject
        expect(response).to have_http_status(:created)
      end
    end

This is simply a mock manipulating whether the code is correct or incorrect. I simply expect 201 to be returned if the code is correct.

Run the test.

expected the response to have status code :created (201) but it was :no_content (204)

This message is displayed So, edit the controller so that 201 is returned in response.

app/controlers/access_token_controller.rb


  def create
    authenticator = UserAuthenticator.new(params[:code])
    authenticator.perform

    render json: {}, status: :created
  end

Add render and return created.

Now run the test again and make sure it passes.

Next, I want to implement it so that it returns a response firmly. So I will write from the test.

spec/controllers/access_token_controller_spec.rb


      it 'should return proper json body' do
        expect{ subject }.to change{ User.count }.by(1)
        user = User.find_by(login: 'a.levine 1')
        expect(json_data['attributes']).to eq(
          { 'token' => user.access_token.token }
        )
      end

Add this test to the end. As for the content of the test, as in the case of article, receive the value with json_data ['attributes'] and check if the content is correct. Since the user fetched by User.find_by is described by the mock using user_data described earlier, the test that the value and the value returned as response are the same.

However, even if I run the test, I can't retrieve it with json_data because I don't use serializer and json.data doesn't exist. So, I will introduce serializer to make a response in a neat format.

serializer generation

$ rails g serializer access_token

This will be described in the created file.

app/serializers/access_token_serializer.rb


class AccessTokenSerializer < ActiveModel::Serializer
  attributes :id, :token
end

Add the description of token. This allows the response to include a token.

And also specify the value to be returned by render in controller.

access_tokens_controller.rb


-    render json: {}, status: :created
+    render json: authenticator.access_token, status: :created
  end

This allows you to return a well-formed response instead of a hash from.

Run the test. Then a message appears.

       expected: {"token"=>"6c7c4213cb78c782f6f6"}
            got: {"token"=>"2e4c724d374019f3fb26"}

Somewhere, the token has been recreated and the value has been switched. This is a bug that tokens are created every time you reload.

So, I will write a test to fix the bug.

spec/models/access_token_spec.rb


    it 'should generate token once' do
      user = create :user
      access_token = user.create_access_token
      expect(access_token.token).to eq(access_token.reload.token)
    end

First, run a test to see if the bug has been reproduced.

expected: "3afe2f824789a229014c" got: "c5e04c73aa7ff89fd0a1"

I was able to reproduce it properly, so I got a message.

Let's improve. First, let's take a look at the buggy generate_token method.

app/models/access_token.rb


def generate_token
  loop do
    break if token.present? && !AccessToken.exists?(token: token)
      self.token = SecureRandom.hex(10)
  end
end 

There is something wrong here, the problem is that the break condition was not good. break if token.present? && !AccessToken.exists?(token: token) This condition has a solid value in token. And the token does not exist in the database. It becomes a condition. But that would be a bit of a contradiction. Since the existence of the token means that it is stored in the database, this conditional expression cannot be satisfied. Therefore, the condition is that there is no token other than the specified token that has the same token.

app/models/access_token.rb


-      break if token.present? && !AccessToken.exists?(token: token)
+      break if token.present? && !AccessToken.where.not(id: id).exists?(token: token)

In this way, it is possible to create a condition called a token other than the currently specified token. Now run the test and make sure it passes.

Logout function

Additional endpoint testing

Now, let's implement the logout function.

spec/routeing/access_token_spec.rb


  it 'should route  to acces_tokens destroy action' do
    expect(delete '/logout').to route_to('access_tokens#destroy')
  end

Write a routing test.

config/routes.rb


Rails.application.routes.draw do
  post 'login', to: 'access_tokens#create'
  delete 'logout', to: 'access_tokens#destroy'
  resources :articles, only: [:index, :show]
end

Added logout line.

The test passes.

Implementation

Next, I will write a controller test.

spec/controllers/access_token_controller.rb


@@ -1,9 +1,9 @@
require 'rails_helper'

RSpec.describe AccessTokensController, type: :controller do
- describe '#create' do
+ describe 'POST #create' do
    shared_examples_for "unauthorized_requests" do
-     let(:error) do
+     let(:authentication_error) do
        {
          "status" => "401",
          "source" => { "pointer" => "/code" },
@ -19,7 +19,7 @@ RSpec.describe AccessTokensController, type: :controller do

      it 'should return proper error body' do
        subject
-       expect(json['errors']).to include(error)
+       expect(json['errors']).to include(authentication_error)
      end
    end

@ -74,4 +74,33 @@ RSpec.describe AccessTokensController, type: :controller do
      end
    end
  end

+ describe 'DELETE #destroy' do
+   context 'when invalid request' do
+     let(:authorization_error) do
+       {
+         "status" => "403",
+         "source" => { "pointer" => "/headers/authorization" },
+         "title" =>  "Not authorized",
+         "detail" => "You have no right to access this resource."
+       }
+     end
+
+       subject { delete :destroy }
+
+     it 'should return 403 status code' do
+       subject
+       expect(response).to have_http_status(:forbidden)
+     end
+
+     it 'should return proper error json' do
+       subject
+       expect(json['errors']).to include(authorization_error)
+     end
+   end
+
+   context 'when valid request' do
+
+   end
+ end
end

Originally treated as an error 403 error, but renamed to clarify the role. Then, I will write a whole test dedicated to destroy. Keep reading the content.

The @@ notation is a code that indicates how many lines are written, and it is not necessary to actually write it.

Then, implement the controller.

app/controllers/access_tokens_controller.rb


  def destroy
    raise AuthorizationError
  end

Define the destroy method. First, in order to pass the error response test, raise the AuthorizationError and actually define the actual state of the error in application_controller.

app/controllers/application_controller.rb


class ApplicationController < ActionController::API
+ class AuthorizationError < StandardError; end
  rescue_from UserAuthenticator::AuthenticationError, with: :authentication_error
+ rescue_from AuthorizationError, with: :authorization_error

  private

@ -12,4 +14,14 @@ class ApplicationController < ActionController::API
    }
    render json: { "errors": [ error ] }, status: 401
  end

+ def authorization_error
+   error = {
+     "status" => "403",
+     "source" => { "pointer" => "/headers/authorization" },
+     "title" =>  "Not authorized",
+     "detail" => "You have no right to access this resource."
+   }
+   render json: { "errors": [ error ] }, status: 403
+ end
end

The content of the error is the same as what I wrote in the test.

Now run the test and make sure it passes.

However, since there is a slightly duplicated description, I will make it DRY.

spec/controllers/access_tokens_controller_spec.rb


  describe 'DELETE #destroy' do
    shared_examples_for 'forbidden_requests' do
    end

First, use shared_examples_for under describe to summarize the description.

The following description is included in shared_examples_for.

spec/controllers/access_tokens_controller_spec.rb


    shared_examples_for 'forbidden_requests' do
      let(:authorization_error) do
        {
          "status" => "403",
          "source" => { "pointer" => "/headers/authorization" },
          "title" =>  "Not authorized",
          "detail" => "You have no right to access this resource."
        }
      end

      it 'should return 403 status code' do
        subject
        expect(response).to have_http_status(:forbidden)
      end

      it 'should return proper error json' do
        subject
        expect(json['errors']).to include(authorization_error)
      end
    end

Combine the tests you have written so far into one.

spec/controllers/access_tokens_controller_spec.rb


    context 'when invalid request' do
      subject { delete :destroy }
      it_behaves_like 'forbidden_requests'
    end

And since it is the description that it_behaves_likes calls shared_expample_for, it calls forbidden_requests specified earlier in the string.

Now that we have created the same environment as before, run it again and make sure the test passes.

Next, we will combine these shared_example_for into one file so that they can be used. There are two shared_example_for in the current ʻaccess_tokens_controller_spec.rb`, so put them together in the same file.

Create spec / support / shared / json_errors.rb

Put the description of shared_example_for in it.

spec/support/shared/json_errors.rb


require 'rails_helper'

shared_examples_for 'forbidden_requests' do

  let(:authorization_error) do
    {
      "status" => "403",
      "source" => { "pointer" => "/headers/authorization" },
      "title" =>  "Not authorized",
      "detail" => "You have no right to access this resource."
    }
  end

  it 'should return 403 status code' do
    subject
    expect(response).to have_http_status(:forbidden)
  end

  it 'should return proper error json' do
    subject
    expect(json['errors']).to include(authorization_error)
  end
end

shared_examples_for "unauthorized_requests" do
  let(:authentication_error) do
    {
      "status" => "401",
      "source" => { "pointer" => "/code" },
      "title" =>  "Authentication code is invalid",
      "detail" => "You must privide valid code in order to exchange it for token."
    }
  end

  it 'should return 401 status code' do
    subject
    expect(response).to have_http_status(401)
  end

  it 'should return proper error body' do
    subject
    expect(json['errors']).to include(authentication_error)
  end
end

Then, all the description of the cutting source is deleted.

spec/controllers/access_tokens_controller_spec.rb


  describe 'DELETE #destroy' do
    subject { delete :destroy }

Raise the nest of subject definition one step. Then add two tests.

spec/controllers/access_tokens_controller_spec.rb


  describe 'DELETE #destroy' do
    subject { delete :destroy }

    context 'when no authorization header provided' do
      it_behaves_like 'forbidden_requests'
    end

    context 'when invalid authorization header provided' do
      before { request.headers['authorization'] = 'Invalid token' }

      it_behaves_like 'forbidden_requests'
    end

    context 'when valid request' do

    end
  end

In this test, the subject is not written because the subject is already written in shared_example_for, so subject {delete: destroy} is automatically called. And if you use before, you can edit the contents of the request. This time, by putting Invalid_token in token, we will create a user who has not been authenticated. Of course, an authentication error will occur, so a test that expects it.

Now run the test to make sure it succeeds.

spec/controllers/access_tokens_controller_spec.rb


    context 'when valid request' do
      let(:user) { create :user }
      let(:access_token) { user.create_access_token }

      before { request.headers['authorization'] = "Bearer #{access_token.token}" }

      it 'should return 204 status code' do
        subject
        expect(response).to have_http_status(:no_content)
      end

      it 'should remove the proper access token' do
        expect{ subject }.to change{ AccessToken.count }.by(-1)
      end
    end

Next, write a test for when valid request. In order to send the correct request, you must first put the token in headers ['authorization'] and pass the permissions. Bearer is bearer authentication, and this time we will use it.

Testing expects the AccessToken model to be reduced by one from the database.

Now make sure that the test fails correctly. Here, typo is often found if you confirm that it fails correctly.

expected the response to have status code :no_content (204) but it was :forbidden (403)



 When I run the test, I get a message like this:

 Forbidden is returned because it is described so that the destroy action always returns an error.

 So we will actually implement the destroy action.


#### **`app/controllers/access_tokens_controller.rb`**
```rb

  def destroy
    raise AuthorizationError
  end

First of all, what I want to do with this destroy is to destroy the access_token of the user who sent the request. So write as follows.

app/controllers/access_tokens_controller.rb


  def destroy
    raise AuthorizationError unless current_user

    current_user.access_token.destroy
  end

current_user refers to the user who is currently logged in. Think about how to bring current_user.

current_user cannot be obtained from request at once. However, if you use request.authorization, the Bearer xxxxxxxxxxxxxxxxxxxxx that you sent in the test earlier

You can get a token like this. So use that token to get the current_user.

app/controllers/access_tokens_controller.rb


  def destroy
    provided_token = request.authorization&.gsub(/\ABearer\s/, '')
    access_token = AccessToken.find_by(token: provided_token)
    current_user = access_token&.user

    raise AuthorizationError unless current_user

    current_user.access_token.destroy
  end

First, in order to get the token with request.authorization and search the token in the database, the gsub method is used to cut it with a regular expression. If you can retrieve only the number part of the token, search with AccessToken.find_by and retrieve it. And if you use that access_token.user, you can retrieve the user who sent the request. And destroy that token Then log out is completed.

The description of &. is called a bocce operator, and if you add it to a method that you know in advance that nil may come back and become unfiind method, an error will occur in the case of nil. Does not appear and nil is returned as a return value as it is, so no error occurs. something like. This time, Invalid_token may be mixed in the request, so in that case nil will be returned, so an error will occur unless the Bocchi operator is used.

Now run the test and make sure it passes all the tests.

Next, we will refactor this code.

app/controllers/access_tokens_controller.rb


   def destroy
-    provided_token = request.authorization&.gsub(/\ABearer\s/, '')
-    access_token = AccessToken.find_by(token: provided_token)
-    current_user = access_token&.user
-
-    raise AuthorizationError unless current_user

    current_user.access_token.destroy
  end

First, cut out the description like this. Then, move the description to application_controller.rb. The reason for this is that the logic that receives this request and generates the current_user is the description that any controller wants to use.

app/controllers/application_controller.rb


  private

  def authorize!
    raise AuthorizationError unless current_user
  end

  def access_token
    provided_token = request.authorization&.gsub(/\ABearer\s/, '')
    @access_token = AccessToken.find_by(token: provided_token)
  end

  def current_user
    @current_user = access_token&.user
  end

Then, write the method privately like this. The authorize! method gives a 401 error when current_user is not included. Get the correct access_token with the access_token method The current_user method retrieves the user for that token. The reason why access_token and current_user are separated here is to clarify their roles and separate responsibilities.

And finally, write so that the defined authorize! Method can always be called.

app/controllers/access_tokens_controller.rb


class AccessTokensController < ApplicationController
  before_action :authorize!, only: :destroy

The situation is always called by before_action. The reason why only destroy is specified is that if it is called during the create action, it will be a method that cannot be called.

These approaches are common, but you forget to write before_action or write too much. So, use skip_before_action and specify the method to skip on the contrary. Basically, as far as the authorize! method is concerned, it seems good to skip even create.

app/controllers/application_controller.rb


  before_action :authorize!

  private

Added a description that is always called above private.

app/controllers/access_tokens_controller.rb


class AccessTokensController < ApplicationController
  skip_before_action :authorize!, only: :create

Change before_action and method.

app/controllers/articles_controller.rb


class ArticlesController < ApplicationController
  skip_before_action :authorize!, only: [:index, :show]

And don't forget to skip article_controller. I want to do index and show without authentication.

Now run the test to see if you get the same results as before the refactoring.

$ bundle exec rspec

Run all the tests and make sure they are all green.

Finally

Thank you for your hard work. With this, we were able to implement the user authentication function that was our initial goal. These may be substituted by using a gem called devise, but depending on whether you know the mechanism or not, the response to problems around user authentication will change, and I think that the degree of understanding is completely different. .. The area around the token is very hard to imagine, and when using oauth, there are still some gems that substitute everything, so the mechanism tends to be black-boxed. So this time, I used user authentication like this.

Continued added

I implemented Rails API with TDD by RSpec. part3

Recommended Posts

I implemented Rails API with TDD by RSpec. part2 -user authentication-
I implemented Rails API with TDD by RSpec. part3-Action implementation with authentication-
I implemented Rails API with TDD by RSpec. part1-Action implementation without authentication-
I rewrote the Rails tutorial test with RSpec
[Rails] Test with RSpec
I created an api domain with Spring Framework. Part 1
Build a bulletin board API with authentication and authorization with Rails # 18 ・ Implementation of final user controller
Build a bulletin board API with authentication authorization with Rails 6 # 3 RSpec, FactoryBot introduced and post model
Build a bulletin board API with authentication authorization in Rails # 12 Association of user and post
[Rails] I implemented the validation error message by asynchronous communication!
[Ruby on Rails] Implement login function by add_token_to_users with API
API creation with Rails + GraphQL
Memorandum [Rails] User authentication Devise
What I was addicted to when implementing google authentication with rails
Implemented authentication function with Spring Security ②
Implemented authentication function with Spring Security ③
REST API test with REST Assured Part 2
Implemented mail sending function with rails
Let's unit test with [rails] Rspec!
# 16 policy setting to build bulletin board API with authentication authorization in Rails 6
# 8 seed implementation to build bulletin board API with authentication authorization in Rails 6
Build a bulletin board API with authentication and authorization with Rails 6 # 1 Environment construction
Build a bulletin board API with authentication authorization in Rails # 13 Add authentication header
Introduced # 9 serializer to build bulletin board API with authentication authorization in Rails 6
Create a SPA with authentication function with Rails API mode + devise_token_auth + Vue.js 3 (Rails edition)