Skip to content

Latest commit

 

History

History
704 lines (594 loc) · 32.4 KB

part64-rus.md

File metadata and controls

704 lines (594 loc) · 32.4 KB

Как протестировать gRPC API, требующий аутентификации

Оригинал

Всем привет, добро пожаловать на мастер-класс по бэкенду! На прошлой лекции мы написали несколько unit тестов для CreateUser RPC. Однако этот API не требует никакой аутентификации, любой может вызвать его без входа в систему и получения токена доступа. Поэтому сегодня я хочу показать вам, как написать unit тесты для UpdateUser RPC, который содержит этот метод authorizeUser()

authPayload, err := server.authorizeUser(ctx)

требующий предоставления действующего токена доступа. Определение этого API дано в файле rpc_update_user.proto.

message UpdateUserRequest {
  string username = 1;
  optional string full_name = 2;
  optional string email = 3;
  optional string password = 4;
}

Его запрос содержит username и некоторые необязательные поля, такие как full_name, email и password, которые можно обновлять. Итак, давайте начнём писать тесты!

Пишем unit тест для gRPC UpdateUser API

Я создам новый файл с названием rpc_update_user_test.go внутри пакета gapi. Затем давайте скопируем модульные тесты, которые мы написали на прошлой лекции, вставим их в новый файл и изменим название функции на TestUpdateUserAPI. Хорошо, теперь давайте изменим тип запроса на pb.UpdateUserRequest. Для этого API у нас нет асинхронной задачи, поэтому давайте удалим фиктивный распределитель задач из метода buildStubs. Затем давайте изменим тип ответа на pb.UpdateUserResponse.

func TestUpdateUserAPI(t *testing.T) {
	user, password := randomUser(t)

	testCases := []struct {
		name          string
		req           *pb.UpdateUserRequest
		buildStubs    func(store *mockdb.MockStore)
		checkResponse func(t *testing.T, res *pb.UpdateUserResponse, err error)
	}{
        ...
    }
    ...
}

Теперь давайте обновим параметры случая OK. Во-первых, тип запроса должен быть UpdateUserRequest. А поскольку поля FullName, Email и Password являются необязательными, их типы являются указателями на строку. Я определю новую переменную для нового имени пользователя и воспользуюсь функцией util.RandomOwner(), чтобы сгенерировать для неё случайное значение. Аналогично, новый адрес электронной почты генерируется с помощью util.RandomEmail().

newName := util.RandomOwner()
newEmail := util.RandomEmail()

Чтобы не усложнять тест я пока не буду изменять пароль. Поэтому давайте удалим это поле из запроса. Затем задайте для поля FullName значение newName. Как видите, VSCode автоматически добавляет в начало переменной символ амперсанда, так как нам нужен указатель на это поле. Точно так же для поля Email, мы зададим значение newEmail.

name: "OK",
req: &pb.UpdateUserRequest{
    Username: user.Username,
    FullName: &newName,
    Email:    &newEmail,
},

Хорошо, теперь давайте удалим фиктивный распределитель задач из функции buildStubs. В этой функции нам придется имитировать запрос UpdateUser, поэтому давайте изменим здесь db.CreateUserTxParams на db.UpdateUserParams. Единственное обязательное поле в этой структуре — Username. Все остальные поля являются необязательными, как видно из их типов, допускающих значение NULL. В этом тестовом случае мы хотим обновить только FullName и Email, поэтому давайте установим для поля Username значение user.Username, для FullNamesql.NullString со значением newName для поля String и true — для Valid. По аналогии поступаем с полем Email, приравниваем его к структуре sql.NullString, у которой поле String имеет значение newEmail, а Validtrue.

arg := db.UpdateUserParams{
    Username: user.Username,
    FullName: sql.NullString{
        String: newName,
        Valid:  true,
    },
    Email: sql.NullString{
        String: newEmail,
        Valid:  true,
    },
}

Теперь нам следует EXPECT (ОЖИДАТЬ) вызов функции UpdateUser с только что объявленными нами аргументами.

store.EXPECT().
    UpdateUser(gomock.Any(), gomock.Eq(arg)).

Здесь я использую сопоставитель gomock.Eq для сравнения входного аргумента. Для успешного тестового случая этот метод должен вызываться ровно один раз и возвращать пользователя с обновлёнными значениями полей вместе с объектом ошибки. Поэтому мы определим здесь переменную updatedUser и вернем её вместе с ошибкой равной nil. Пользователь с обновлёнными значениями полей будет объектом db.User, где все поля останутся такими же, как и у старого пользователя, за исключением FullName и Email, значения которых должны быть равны newName и newEmail.

updatedUser := db.User{
    Username:          user.Username,
    HashedPassword:    user.HashedPassword,
    FullName:          newName,
    Email:             newEmail,
    PasswordChangedAt: user.PasswordChangedAt,
    CreatedAt:         user.CreatedAt,
    IsEmailVerified:   user.IsEmailVerified,
}
store.EXPECT().
    UpdateUser(gomock.Any(), gomock.Eq(arg)).
    Times(1).
    Return(updatedUser, nil)

Хорошо, теперь мы можем удалить команду EXPECT для фиктивного распределителя задач. И перейти к функции checkResponse.

buildStubs: func(store *mockdb.MockStore) {
    arg := db.UpdateUserParams{
        Username: user.Username,
        FullName: sql.NullString{
            String: newName,
            Valid:  true,
        },
        Email: sql.NullString{
            String: newEmail,
            Valid:  true,
        },
    }
    updatedUser := db.User{
        Username:          user.Username,
        HashedPassword:    user.HashedPassword,
        FullName:          newName,
        Email:             newEmail,
        PasswordChangedAt: user.PasswordChangedAt,
        CreatedAt:         user.CreatedAt,
        IsEmailVerified:   user.IsEmailVerified,
    }
    store.EXPECT().
        UpdateUser(gomock.Any(), gomock.Eq(arg)).
        Times(1).
        Return(updatedUser, nil)
},

В ходе этого теста она получит UpdateUserResponse и объект ошибки в качестве результата работы API. Поскольку это успешный случай, мы ожидаем отсутствия ошибок и результат не должен быть равен nil. Мы извлечём пользователя с обновлёнными значениями полей из ответа и проверим, что имя пользователя осталось прежним, а полное имя и фамилия были изменены на newName и адрес электронной почты — на newEmail. Вот и всё!

checkResponse: func(t *testing.T, res *pb.UpdateUserResponse, err error) {
    require.NoError(t, err)
    require.NotNil(t, res)
    updatedUser := res.GetUser()
    require.Equal(t, user.Username, updatedUser.Username)
    require.Equal(t, newName, updatedUser.FullName)
    require.Equal(t, newEmail, updatedUser.Email)
},

Мы внесли необходимые изменения для случая OK. Пока что я удалю все остальные тестовые случаи, чтобы мы могли сосредоточиться на нём и в первую очередь сделать так, чтобы он был успешно пройден.

Хорошо, давайте модифицируем содержимое функции Run!

Мы можем избавиться от контроллера и распределителя задач. Удалите распределитель задач при создании заглушек и просто используйте nil в качестве распределителя задач при создании нового тестового сервера. Здесь нам следует вызывать server.UpdateUser вместо CreateUser. И это всё что нам нужно сделать!

t.Run(tc.name, func(t *testing.T) {
    storeCtrl := gomock.NewController(t)
    defer storeCtrl.Finish()
    store := mockdb.NewMockStore(storeCtrl)

    tc.buildStubs(store)
    server := newTestServer(t, store, nil)

    res, err := server.UpdateUser(context.Background(), tc.req)
    tc.checkResponse(t, res, err)
})

Здесь компилятор предупреждает нас, что эта переменная password Oh, the compiler is still complaining about this password variable

user, password := randomUser(t)

не используется, поэтому я заменю её пустым идентификатором.

user, _ := randomUser(t)

И теперь у нас всё готово к работе! Давайте запустим тест и посмотрим, что произойдет!

go test -timeout 30s -run ^TestUpdateUserAPI$ github.com/techschool/simplebank/gapi -v count=1
=== RUN   TestUpdateUserAPI
=== RUN   TestUpdateUserAPI/OK
    /Users/quangpham/Projects/techschool/simplebank/gapi/rpc_update_user_test.go:62: 
            Error Trace:    rpc_update_user_test.go:62
                                                    rpc_update_user_test.go:84
            Error:          Received unexpected error:
                            rpc error: code = Unauthenticated desc = unauthorized: missing metadata

Он не был пройден! И возникла следующая ошибка Unauthenticated: missing metadata («Пользователь неаутентифицирован: отсутствуют метаданные»). Такая реакция вполне ожидаема, поскольку RPC UpdateUser требует, чтобы действующий токен доступа был отправлен через метаданные контекста запроса, но мы еще не добавили ни одного токена доступа в контекст. Здесь, в функции authorizeUser,

func (server *Server) authorizeUser(ctx context.Context) (*token.Payload, error) {
	md, ok := metadata.FromIncomingContext(ctx)
	if !ok {
		return nil, fmt.Errorf("missing metadata")
	}

	values := md.Get(authorizationHeader)
	if len(values) == 0 {
		return nil, fmt.Errorf("missing authorization header")
	}

	authHeader := values[0]
	fields := strings.Fields(authHeader)
	if len(fields) < 2 {
		return nil, fmt.Errorf("invalid authorization header format")
	}

	authType := strings.ToLower(fields[0])
	if authType != authorizationBearer {
		return nil, fmt.Errorf("unsupported authorization type: %s", authType)
	}

	accessToken := fields[1]
	payload, err := server.tokenMaker.VerifyToken(accessToken)
	if err != nil {
		return nil, fmt.Errorf("invalid access token: %s", err)
	}

	return payload, nil
}

мы пытаемся получить метаданные из входящего контекста, затем заголовок авторизации из метаданных, и значение этого заголовка должно содержать токен доступа Bearer. Затем мы используем tokenMaker чтобы убедиться, что токен действующий. Итак, в unit тесте нам нужно будет добавить действующий токен доступа к метаданным контекста перед отправкой запроса.

Для этого я добавлю метод buildContext в структуру c тестовыми случаями. В качестве входных аргументов он примет объект test.T и интерфейс token.Maker и вернет объект контекста, содержащий метаданные с токеном доступа.

testCases := []struct {
    name          string
    req           *pb.UpdateUserRequest
    buildStubs    func(store *mockdb.MockStore)
    buildContext  func(t *testing.T, tokenMaker token.Maker) context.Context
    checkResponse func(t *testing.T, res *pb.UpdateUserResponse, err error)
}{
    ...
}

Мы сделали этот метод в структуру с тестовыми случаями, потому что для разных случаев у нас могут быть разные метаданные, например, с не действительным токеном доступа или с истекшим сроком действия или мы даже можем возвращать context.Background(), если не хотим отправлять токен.

buildContext: func(t *testing.T, tokenMaker token.Maker) context.Context {
    return context.Background()
},

В функции Run теста мы будем вызывать tc.buildContext() и передавать объект t и server.tokenMaker, чтобы получить контекст с добавленными метаданными. Затем будем использовать этот контекст в качестве первого аргумента при вызове UpdateUser.

ctx := tc.buildContext(t, server.tokenMaker)
res, err := server.UpdateUser(ctx, tc.req)

Хорошо, теперь для успешного тестового случая нам нужно будет добавить действующий токен доступа к метаданным контекста. Итак, сначала я присвою этот context.Background() переменной ctx. Затем мы можем использовать функцию NewIncomingContext пакета metadata из grpc, чтобы добавить объект метаданных (md) в фоновый контекст.

ctx := context.Background()
return metadata.NewIncomingContext(ctx, md)

Мы можем создать объект метаданных с помощью структуры metadata.MD. К этому объекту мы добавим заголовок авторизации с токеном доступа. Обратите внимание, что объект md — это просто карта со строковым ключом и значениями типа срез строк, поэтому здесь нам нужно объявить срез строк только с одним элементом: BearerToken.

md := metadata.MD{
    authorizationHeader: []string{
        bearerToken,
    },
}

На следующем шаге я создам bearerToken с помощью функции fmt.Sprint(). Он будет состоять из двух подстрок, разделенных пробелом. Первая — это тип авторизации, в данном случае "bearer". И вторая — это токен доступа, который мы скоро создадим. Здесь мы будем использовать tokenMaker, чтобы создать действующий токен доступа.

tokenMaker.CreateToken()
bearerToken := fmt.Sprintf("%s %s", authorizationBearer, accessToken)

Я передаю этому методу CreateToken имя пользователя и корректное значение для продолжительности периода времени в 1 минуту. Этот метод вернет строку c токеном доступа, полезную нагрузку токена и ошибку. Мы не используем полезную нагрузку токена, поэтому я воспользуюсь пустым идентификатором. Затем проверим, что возвращаемая ошибка равна nil с помощью функции require.NoError().

accessToken, _, err := tokenMaker.CreateToken(user.Username, time.Minute)
require.NoError(t, err)

Мы можем немножко сократить код, поместив фоновый контекст в начале функции (ctx := context.Background()) непосредственно в этот вызов metadata.NewIncomingContext(ctx, md). И на этом по сути всё. Мы закончили с написанием функции для добавления токена доступа к метаданным контекста.

buildContext: func(t *testing.T, tokenMaker token.Maker) context.Context {
    ctx := context.Background()
    accessToken, _, err := tokenMaker.CreateToken(user.Username, time.Minute)
    require.NoError(t, err)
    bearerToken := fmt.Sprintf("%s %s", authorizationBearer, accessToken)
    md := metadata.MD{
        authorizationHeader: []string{
            bearerToken,
        },
    }
    return metadata.NewIncomingContext(ctx, md)
},

Давайте повторно запустим тест и посмотрим, что произойдет!

go test -timeout 30s -run ^TestUpdateUserAPI$ github.com/techschool/simplebank/gapi -v count=1
=== RUN   TestUpdateUserAPI
=== RUN   TestUpdateUserAPI/OK
--- PASS: TestUpdateUserAPI (0.07s)
    --- PASS: TestUpdateUserAPI/OK (0.00s)
PASS
ok      github.com/techschool/simplebank/gapi     0.410s

На этот раз тест успешно пройден! Отлично!

Далее попробуем добавить еще несколько тестовых случаев для проверки других ситуаций, например, когда пользователь не найден. Я скопирую OK случай и изменю его название на UserNotFound.

name: "UserNotFound",
req: &pb.UpdateUserRequest{
    Username: user.Username,
    FullName: &newName,
    Email:    &newEmail,
},

Поскольку это тестовый случай, когда возникает ошибка, в функции buildStubs нам не нужны реальные параметры в качестве аргументов функции UpdateUser или пользователь с обновлёнными значениями полей, так что давайте от них избавимся.

buildStubs: func(store *mockdb.MockStore) {
    store.EXPECT().
        UpdateUser(gomock.Any(), gomock.Any()).
        Times(1).
        Return(db.User{}, sql.ErrNoRows)
},

Здесь мы можем просто использовать gomock.Any(), что соответствует произвольному значению параметров и возвращаем пустой объект пользователя с ошибкой: sql.ErrNoRows. Драйвер БД вернет эту ошибку, если пользователь не найден в базе данных.

Теперь, поскольку функция buildContext точно такая же, как и в случае OK, давайте проведем рефакторинг кода, чтобы у нас не было дублирующихся частей. Этот метод на самом деле можно вынести в глобальную функцию, которую можно будет повторно использовать и в других RPC unit тестах. Поэтому я помещу его в файл main_test.go и назову newContextWithBearerToken(). Нам придется добавить сюда параметр username, поскольку объект user недоступен в этой функции. Затем передадим username в вызов tokenMaker.CreateToken(). Кстати, я думаю, нам также следует изменить это жестко закодированное значение time.Minute на параметр длительности, чтобы мы могли использовать отрицательное значение для получения токена доступа с истекшим сроком действия при тестировании этого случая. Хорошо, теперь вернемся к unit тестам!

func newContextWithBearerToken(t *testing.T, tokenMaker token.Maker, username string, duration time.Duration) context.Context {
	ctx := context.Background()
	accessToken, _, err := tokenMaker.CreateToken(username, duration)
	require.NoError(t, err)
	bearerToken := fmt.Sprintf("%s %s", authorizationBearer, accessToken)
	md := metadata.MD{
		authorizationHeader: []string{
			bearerToken,
		},
	}
	return metadata.NewIncomingContext(ctx, md)
}

Я удалю все тело функции buildContext и заменю её только одной строкой, где возвращается результат выполнения newContextWithBearerToken(). Передадим t, tokenMaker, user.Username и time.Minute в качестве входных аргументов.

buildContext: func(t *testing.T, tokenMaker token.Maker) context.Context {
    return newContextWithBearerToken(t, tokenMaker, user.Username, time.Minute)
},

Этот же фрагмент кода можно использовать в buildContext для случая UserNotFound. Теперь код стал выглядеть гораздо лаконичнее, не так ли?

Далее, в функции checkResponse вместо того, чтобы требовать отсутствия ошибок, мы будем требовать возврата ненулевой ошибки. Нам также следует проверить код состояния, поэтому давайте скопируем его из тестового случая, когда возникает ошибка, который мы написали при тестировании CreateUser на предыдущей лекции.

st, ok := status.FromError(err)
require.True(t, ok)
require.Equal(t, codes.NotFound, st.Code())

По сути, мы преобразуем возвращаемую ошибку в объект состояния и требуем, чтобы поле Code этого объекта было NotFound, поскольку это значение должно быть возвращено сервером в этом случае.

Хорошо, всё готово. Теперь пора повторно запустить повторить тест!

go test -timeout 30s -run ^TestUpdateUserAPI$ github.com/techschool/simplebank/gapi -v count=1
=== RUN   TestUpdateUserAPI
=== RUN   TestUpdateUserAPI/OK
=== RUN   TestUpdateUserAPI/UserNotFound
--- PASS: TestUpdateUserAPI (0.07s)
    --- PASS: TestUpdateUserAPI/OK (0.00s)
    --- PASS: TestUpdateUserAPI/UserNotFound (0.00s)
PASS
ok      github.com/techschool/simplebank/gapi     0.385s

Он успешно пройден. Превосходно! Остальные тестовые случаи могут быть написаны подобным образом, поэтому давайте продублируем этот случай "UserNotFound" и протестируем случай, когда у токена истёк срок действия.

name: "ExpiredToken",
req: &pb.UpdateUserRequest{
    Username: user.Username,
    FullName: &newName,
    Email:    &newEmail,
},

В этом случае мы ожидаем, что функция UpdateUser не будет вызываться (или вызовется 0 раз).

buildStubs: func(store *mockdb.MockStore) {
    store.EXPECT().
        UpdateUser(gomock.Any(), gomock.Any()).
        Times(0)
},

Поскольку станет понятно, что запрос не нужно выполнять уже при вызове authorizeUser().

И, как я уже говорил, мы можем создать контекст с токеном, у которого истёк срок действия. Для этого достаточно передать отрицательное значение для параметра duration.

buildContext: func(t *testing.T, tokenMaker token.Maker) context.Context {
    return newContextWithBearerToken(t, tokenMaker, user.Username, -time.Minute)
},

Наконец, в функции checkResponse мы ожидаем, что код состояния будет равен Unauthenticated. Вот и всё что нужно было изменить!

checkResponse: func(t *testing.T, res *pb.UpdateUserResponse, err error) {
    require.Error(t, err)
    st, ok := status.FromError(err)
    require.True(t, ok)
    require.Equal(t, codes.Unauthenticated, st.Code())
},

Давайте повторно запустим тесты!

go test -timeout 30s -run ^TestUpdateUserAPI$ github.com/techschool/simplebank/gapi -v count=1
=== RUN   TestUpdateUserAPI
=== RUN   TestUpdateUserAPI/OK
=== RUN   TestUpdateUserAPI/UserNotFound
=== RUN   TestUpdateUserAPI/ExpiredToken
--- PASS: TestUpdateUserAPI (0.07s)
    --- PASS: TestUpdateUserAPI/OK (0.00s)
    --- PASS: TestUpdateUserAPI/UserNotFound (0.00s)
    --- PASS: TestUpdateUserAPI/ExpiredToken (0.00s)
PASS
ok      github.com/techschool/simplebank/gapi     0.380s

Все они успешно пройдены!

Попробуете добавить другие случаи самостоятельно? Например, тест No Authorization, когда пользователь не предоставляет данных для его аутентификации (или токен доступа).

name: "NoAuthorization",
req: &pb.UpdateUserRequest{
    Username: user.Username,
    FullName: &newName,
    Email:    &newEmail,
},

Довольно просто, не так ли? Все, что нам нужно сделать, это изменить в операторе return функции buildContext значение на context.Background(). Затем повторно запустим тесты.

go test -timeout 30s -run ^TestUpdateUserAPI$ github.com/techschool/simplebank/gapi -v count=1
=== RUN   TestUpdateUserAPI
=== RUN   TestUpdateUserAPI/OK
=== RUN   TestUpdateUserAPI/UserNotFound
=== RUN   TestUpdateUserAPI/ExpiredToken
=== RUN   TestUpdateUserAPI/NoAuthorization
--- PASS: TestUpdateUserAPI (0.07s)
    --- PASS: TestUpdateUserAPI/OK (0.00s)
    --- PASS: TestUpdateUserAPI/UserNotFound (0.00s)
    --- PASS: TestUpdateUserAPI/ExpiredToken (0.00s)
    --- PASS: TestUpdateUserAPI/NoAuthorization (0.00s)
PASS
ok      github.com/techschool/simplebank/gapi     0.383s

И вуаля, все они опять успешно пройдены!

Прежде чем мы закончим, я покажу вам ещё один, последний на сегодня тестовый случай, когда указан неправильный адрес электронной почты. В этом случае мы не можем просто использовать символ амперсанда для получения адреса строковой константы вот так &invalid-email. Поэтому нам придётся использовать переменную с названием invalidEmail, и объявить её в верхней части функции следующим образом.

newName := util.RandomOwner()
newEmail := util.RandomEmail()
invalidEmail := "invalid-email"
name: "InvalidEmail",
req: &pb.UpdateUserRequest{
    Username: user.Username,
    FullName: &newName,
    Email:    &invalidEmail,
},

Теперь функция UpdateUser должна вызываться 0 раз, а контекст должен содержать действующий токен доступа.

buildStubs: func(store *mockdb.MockStore) {
    store.EXPECT().
        UpdateUser(gomock.Any(), gomock.Any()).
        Times(0)
},
buildContext: func(t *testing.T, tokenMaker token.Maker) context.Context {
    return newContextWithBearerToken(t, tokenMaker, user.Username, time.Minute)
},

Но на этот раз мы ожидаем, что сервер вернет код состояния InvalidArgument.

checkResponse: func(t *testing.T, res *pb.UpdateUserResponse, err error) {
    require.Error(t, err)
    st, ok := status.FromError(err)
    require.True(t, ok)
    require.Equal(t, codes.InvalidArgument, st.Code())
},

Вот и всё! Мы внесли все необходимые изменения! Давайте повторно запустим тесты ещё один, последний раз!

go test -timeout 30s -run ^TestUpdateUserAPI$ github.com/techschool/simplebank/gapi -v count=1
=== RUN   TestUpdateUserAPI
=== RUN   TestUpdateUserAPI/OK
=== RUN   TestUpdateUserAPI/UserNotFound
=== RUN   TestUpdateUserAPI/ExpiredToken
=== RUN   TestUpdateUserAPI/NoAuthorization
=== RUN   TestUpdateUserAPI/InvalidEmail
--- PASS: TestUpdateUserAPI (0.07s)
    --- PASS: TestUpdateUserAPI/OK (0.00s)
    --- PASS: TestUpdateUserAPI/UserNotFound (0.00s)
    --- PASS: TestUpdateUserAPI/ExpiredToken (0.00s)
    --- PASS: TestUpdateUserAPI/NoAuthorization (0.00s)
    --- PASS: TestUpdateUserAPI/InvalidEmail (0.00s)
PASS
ok      github.com/techschool/simplebank/gapi     0.407s

Все тесты были успешно пройдены.

Итак, теперь вы знаете, как написать unit тесты для gRPC API, требующий аутентификации. На основе этой реализации вы можете добавить другие тестовые случаи для этого API обновляющего значения полей пользователя или самостоятельно написать тесты для других API в проекте!

И на этом мы закончим сегодняшнюю лекцию.

Я надеюсь, что она была интересной и полезной для вас. Большое спасибо за время, потраченное на чтение! Желаю вам получать удовольствие от обучения и до встречи на следующей лекции!