Опирается на правила: GOTEST-29, GOTEST-30, GOTEST-X10, GOTEST-X11 из Go Test Strategy → раздел 9. Авторизация в тестах.

Важно знать

  • Реальный JWKS/Keycloak не поднимаем — подменяем JWT-валидатор на fakeAuthMiddleware, которая кладёт готовый Principal в контекст.
  • fakeAuthMiddleware(principal) — единственный способ прокинуть авторизацию в тестовый роутер; не отключаем middleware целиком.
  • Хелперы AdminPrincipal() / CustomerPrincipal(id) живут в пакете testhelper — единый source-of-truth; не собираем Principal вручную в каждом тесте.
  • Сценарий с неверной ролью обязателен — тест должен убедиться, что эндпоинт вернёт 403, если роль не соответствует.
  • 401 Unauthorized проверяется отдельным тестом без middleware — запрос без токена должен возвращать 401.
  • newTestServer(t, principal) принимает Principal как параметр — каждый тест явно выбирает роль, без глобального состояния.
  • t.Cleanup(srv.Close) регистрируется в newTestServer — утечек соединений нет.
  • Отключение auth-middleware маскирует баги авторизации, которые всплывут только в проде.

UCP-подход к авторизации в тестах решает один вопрос: как убедиться, что RBAC-логика работает, не поднимая внешний identity-провайдер. В Java это TestJwtConfiguration + TestHttpHeaders.withAdminToken(). В Go — fakeAuthMiddleware + testhelper с фиксированными Principal-значениями.

GOTEST-29 — fakeAuthMiddleware вместо реального JWT-валидатора

Продакшн-роутер проверяет подпись токена через JWKS-эндпоинт. В тесте подпись нас не интересует — нас интересует, как сервис ведёт себя при конкретной роли. Поэтому подменяем middleware целиком:

func fakeAuthMiddleware(principal auth.Principal) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            ctx := auth.WithPrincipal(r.Context(), principal)
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

auth.WithPrincipal — та же функция, что использует продакшн-middleware. Обработчик не знает, откуда пришёл Principal: из реального токена или из теста. Это и есть цель подмены.

newTestServer принимает Principal явным параметром и регистрирует fakeAuthMiddleware вместо продакшн-валидатора:

func newTestServer(t *testing.T, principal auth.Principal) (*httptest.Server, *OrderDatabasePreparer) {
    t.Helper()
    pool, _ := pgxpool.New(context.Background(), testDSN)
    t.Cleanup(pool.Close)

    q := db.New(pool)
    repo := order.NewRepository(q)
    clock := &fixedClock{at: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)}
    ids := &seqIDGenerator{}
    svc := order.NewService(repo, clock, ids)

    r := chi.NewRouter()
    r.Use(fakeAuthMiddleware(principal))
    orderhttp.Mount(r, svc)

    srv := httptest.NewServer(r)
    t.Cleanup(srv.Close)

    return srv, &OrderDatabasePreparer{pool: pool}
}

Роутер собирается в тесте — можно поставить любой middleware в любой позиции без изменения продакшн-кода.

GOTEST-30 — хелперы ролей в testhelper

Пакет testhelper — единственное место, где определяются тестовые Principal-значения. Не дублируем Principal{ID: "...", Role: "..."} в каждом тесте:

// testhelper/auth.go

package testhelper

import "myservice/internal/auth"

func AdminPrincipal() auth.Principal {
    return auth.Principal{ID: "admin-1", Role: "admin"}
}

func CustomerPrincipal(id string) auth.Principal {
    return auth.Principal{ID: id, Role: "customer"}
}

func SberManagerPrincipal(id string) auth.Principal {
    return auth.Principal{ID: id, Role: "sber-manager"}
}

Зачем один пакет: если Principal поменяет структуру (добавится поле Permissions, TenantID) — меняем в одном месте, все тесты подхватят автоматически.

Пример теста с успешной авторизацией клиента через CustomerPrincipal:

func TestGetOrder_AsCustomer_ReturnsOrder(t *testing.T) {
    customerID := "cust-42"
    srv, prep := newTestServer(t, testhelper.CustomerPrincipal(customerID))

    // Arrange
    prep.Clear(t).
        CreateCustomer(t, customerID).
        CreateOrder(t, "order-1", customerID, "CONFIRMED")

    // Act
    req, _ := http.NewRequest(http.MethodGet, srv.URL+"/orders/order-1", nil)
    resp, err := http.DefaultClient.Do(req)
    require.NoError(t, err)
    defer resp.Body.Close()

    // Assert
    require.Equal(t, http.StatusOK, resp.StatusCode)

    var body map[string]any
    require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
    assert.Equal(t, "order-1", body["id"])
    assert.Equal(t, "CONFIRMED", body["status"])
}

GOTEST-X11 — Auth-middleware не отключать, 403 проверять явно

Соблазн «упростить тест» — убрать auth-middleware из тестового роутера совсем. Это ошибка: тест перестаёт проверять, что endpoint защищён. Продакшн может сломаться по-тихому.

Правило: каждый защищённый эндпоинт должен иметь хотя бы один тест с неверной ролью, который проверяет 403:

func TestCreateOrder_AsCustomer_Forbidden(t *testing.T) {
    srv, prep := newTestServer(t, testhelper.CustomerPrincipal("cust-1"))
    prep.Clear(t).CreateCustomer(t, "cust-1")

    // Act — customer пытается вызвать эндпоинт только для sber-manager
    resp, err := http.Post(srv.URL+"/orders/bulk-import", "application/json",
        strings.NewReader(`{"items":[]}`))
    require.NoError(t, err)
    defer resp.Body.Close()

    // Assert
    assert.Equal(t, http.StatusForbidden, resp.StatusCode)
}

И отдельный тест на 401 — запрос вовсе без токена. Для этого поднимаем роутер без fakeAuthMiddleware (с продакшн-middleware, которая умеет возвращать 401 на отсутствующий токен):

func TestCreateOrder_NoToken_Unauthorized(t *testing.T) {
    pool, _ := pgxpool.New(context.Background(), testDSN)
    t.Cleanup(pool.Close)

    r := chi.NewRouter()
    r.Use(auth.RequireToken()) // продакшн-middleware: 401 если нет заголовка
    orderhttp.Mount(r, order.NewService(order.NewRepository(db.New(pool)),
        &fixedClock{}, &seqIDGenerator{}))

    srv := httptest.NewServer(r)
    t.Cleanup(srv.Close)

    resp, err := http.Post(srv.URL+"/orders", "application/json",
        strings.NewReader(`{"customerId":"c1","amount":100}`))
    require.NoError(t, err)
    defer resp.Body.Close()

    assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
}

Роли для других Bounded Context: Product, Customer

Если сервис работает в домене Product/Sber (каталог, контракты), хелперы остаются теми же — меняется только набор ролей:

// testhelper/auth.go (для product-сервиса)

func ProductManagerPrincipal(id string) auth.Principal {
    return auth.Principal{ID: id, Role: "product-manager"}
}

func SberContractPrincipal(id string) auth.Principal {
    return auth.Principal{ID: id, Role: "sber-contract"}
}

Тест на проверку доступа к черновику продукта:

func TestGetProductDraft_AsProductManager_ReturnsProduct(t *testing.T) {
    managerID := "pm-7"
    srv, prep := newTestServer(t, testhelper.ProductManagerPrincipal(managerID))

    // Arrange
    prep.Clear(t).
        CreateProduct(t, "prod-1", "DRAFT").
        AssignManager(t, "prod-1", managerID)

    // Act
    req, _ := http.NewRequest(http.MethodGet, srv.URL+"/products/prod-1", nil)
    resp, err := http.DefaultClient.Do(req)
    require.NoError(t, err)
    defer resp.Body.Close()

    // Assert
    require.Equal(t, http.StatusOK, resp.StatusCode)

    var body map[string]any
    require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
    assert.Equal(t, "DRAFT", body["status"])
}

func TestGetProductDraft_AsCustomer_Forbidden(t *testing.T) {
    srv, prep := newTestServer(t, testhelper.CustomerPrincipal("cust-99"))
    prep.Clear(t).CreateProduct(t, "prod-1", "DRAFT")

    req, _ := http.NewRequest(http.MethodGet, srv.URL+"/products/prod-1", nil)
    resp, err := http.DefaultClient.Do(req)
    require.NoError(t, err)
    defer resp.Body.Close()

    assert.Equal(t, http.StatusForbidden, resp.StatusCode)
}

Что запрещено

АнтипаттернПравилоЧто взамен
Реальный Keycloak/JWKS в интеграционном тестеGOTEST-X10fakeAuthMiddleware с готовым Principal
Отключить auth-middleware в тестовом роутереGOTEST-X11fakeAuthMiddleware с нужной ролью; тест 403/401 обязателен
Собирать Principal{...} руками в каждом тестеGOTEST-30хелперы AdminPrincipal() / CustomerPrincipal(id) в testhelper
Один тест проверяет и happy path, и 403GOTEST-3один тест — один сценарий
t.Parallel() с общим TRUNCATE без транзакцииGOTEST-18изоляция через tx.Rollback в t.Cleanup
testify/mock для auth-интерфейса вместо fakeAuthMiddlewareGOTEST-X10подмена middleware целиком

Куда дальше

  • Структура одного теста — AAA, имена Test<Action>_<Condition>_<Expected>, require vs assert.
  • Инфраструктура теста — TestMain + контейнер — TestMain, newTestServer, testDSN.
  • DatabasePreparer в Go — Clear(t), fluent Create*(t, ...), порядок FK.
  • Пирамида тестов — unit на агрегат без инфраструктуры, unit на контроллер с httptest.NewRecorder.
  • Авторизация — нормативный гайдPrincipal, ABAC, ownership-check.