Files
glchat/internal/server/oauth_test.go
T
grendervill cebce0ae3b feat(auth): вход через OAuth-провайдеры (GitHub, Google, Discord)
Фаза 7, AGENT.md 7.1: провайдеры включаются переменными окружения
OAUTH_<PROVIDER>_CLIENT_ID/SECRET, привязка внешнего аккаунта идёт по
подтверждённому провайдером email через blind index, вход забаненному на
инстансе запрещён, все входы и привязки попадают в события безопасности,
привязка — ещё и в аудит инстанса.

Внешний идентификатор (subject) хранится только индексом HMAC-SHA-256,
токены провайдеров не сохраняются вовсе; state подписывается ключом сессий
и ограничен по времени, редирект после входа — только внутренний путь.
Если провайдер не настроен, ручка отвечает oauth.provider_not_configured,
а клиент показывает человеческий текст вместо кнопки.

Клиент: кнопки входа по списку включённых провайдеров на экране входа и
раздел «Вход через внешние сервисы» в настройках безопасности; адрес
возврата для настроек приложения виден в GET /auth/oauth/providers.

Миграция 00019 добавляет таблицу oauth_accounts. Установщик и .env.example
знают про OAUTH_* и сохраняют значения при --reconfigure.

Тесты: Go — выключенный провайдер, подписанный state (подмена и чужой
провайдер), создание и повторный вход, привязка к существующему аккаунту,
неподтверждённый email, выключенная регистрация, бан инстанса, mocked
провайдер (httptest) для обмена кода; web — кнопки провайдеров и ошибка
возврата; серверная ручка отдаёт понятный отказ без настроек.
2026-09-26 15:13:24 +03:00

82 lines
3.1 KiB
Go

package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestOAuthProviderNotConfigured — ручка входа через провайдера отвечает
// понятной ошибкой, пока оператор не задал OAUTH_* (Фаза 7).
func TestOAuthProviderNotConfigured(t *testing.T) {
srv, _ := newTestServer(t)
rec := doJSON(t, srv, http.MethodGet, "/api/v1/auth/oauth/github/start", "")
if rec.Code != http.StatusNotFound {
t.Fatalf("oauth start without config = %d, want 404, body = %s", rec.Code, rec.Body.String())
}
if code := errorCodeOf(t, rec); code != "oauth.provider_not_configured" {
t.Fatalf("error code = %q, want oauth.provider_not_configured", code)
}
rec = doJSON(t, srv, http.MethodGet, "/api/v1/auth/oauth/vk/start", "")
if rec.Code != http.StatusNotFound {
t.Fatalf("unknown provider = %d, want 404", rec.Code)
}
if code := errorCodeOf(t, rec); code != "oauth.provider_unknown" {
t.Fatalf("error code = %q, want oauth.provider_unknown", code)
}
// Список провайдеров пуст, клиент не рисует кнопки.
rec = doJSON(t, srv, http.MethodGet, "/api/v1/auth/oauth/providers", "")
if rec.Code != http.StatusOK {
t.Fatalf("providers list = %d, body = %s", rec.Code, rec.Body.String())
}
var payload struct {
Providers []struct {
ID string `json:"id"`
} `json:"providers"`
RedirectURL string `json:"redirect_url"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode providers: %v", err)
}
if len(payload.Providers) != 0 {
t.Fatalf("providers = %s, want empty", rec.Body.String())
}
// Мета инстанса честно сообщает, что провайдеры выключены.
meta := doJSON(t, srv, http.MethodGet, "/api/v1/meta", "")
if meta.Code != http.StatusOK {
t.Fatalf("meta = %d", meta.Code)
}
if !strings.Contains(meta.Body.String(), `"oauth_enabled":false`) {
t.Fatalf("meta must report oauth_enabled=false: %s", meta.Body.String())
}
if !strings.Contains(meta.Body.String(), `"passkeys_enabled":true`) {
t.Fatalf("meta must report passkeys_enabled=true for a domain: %s", meta.Body.String())
}
}
// TestOAuthCallbackRejectsUnsignedState — колбэк без валидного state
// возвращает пользователя на вход с кодом ошибки, а не создаёт сессию.
func TestOAuthCallbackRejectsUnsignedState(t *testing.T) {
srv, _ := newTestServer(t)
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
"/api/v1/auth/oauth/github/callback?code=abc&state=forged", nil)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, request)
if rec.Code != http.StatusFound {
t.Fatalf("callback with forged state = %d, want 302, body = %s", rec.Code, rec.Body.String())
}
location := rec.Header().Get("Location")
if !strings.HasPrefix(location, "/login?oauth_error=") {
t.Fatalf("callback must redirect to the login page with a code, got %q", location)
}
if len(rec.Result().Cookies()) != 0 {
t.Fatal("failed oauth callback must not set a session cookie")
}
}