package auth import ( "bytes" "context" "encoding/base64" "encoding/json" "errors" "log/slog" "net/http" "net/http/httptest" "net/url" "path/filepath" "strconv" "strings" "testing" "time" "golang.org/x/oauth2" "glchat/internal/config" "glchat/internal/crypto" "glchat/internal/database" "glchat/internal/store" ) // oauthTestConfig — конфиг тестового сервиса с включённым GitHub-провайдером. func oauthTestConfig() config.Config { return config.Config{ Domain: "gl.mhspx.su", InstanceName: "glchat", SessionPepper: "unit-test-session-pepper", MasterKey: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", TOTPEncryptionKey: "unit-test-totp-key", SessionTTLHours: 24, // Низкие параметры Argon2id: тесты не должны работать секундами. Argon2MemoryKiB: 1024, Argon2Iterations: 1, Argon2Parallelism: 1, OAuthGitHubClientID: "client-id", OAuthGitHubClientSecret: "client-secret", } } // oauthTestBaseConfig — конфиг без OAuth: проверяем выключенные провайдеры. func oauthTestBaseConfig() config.Config { cfg := oauthTestConfig() cfg.OAuthGitHubClientID = "" cfg.OAuthGitHubClientSecret = "" return cfg } // oauthTestServer поднимает фальшивого провайдера: обмен кода на токен и // профиль. Адреса подставляются в каталог провайдеров на время теста. func oauthTestServer(t *testing.T, profile oauthProfile) *httptest.Server { t.Helper() mux := http.NewServeMux() mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { w.WriteHeader(http.StatusBadRequest) return } if r.Form.Get("code") != "good-code" { w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(`{"error":"invalid_grant"}`)) return } // Провайдер отвечает JSON: так же ведёт себя GitHub при // `Accept: application/json`, который ставит golang.org/x/oauth2. w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "access_token": "test-access-token", "token_type": "bearer", "expires_in": 3600, }) }) mux.HandleFunc("/profile", func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Authorization") != "Bearer test-access-token" { w.WriteHeader(http.StatusUnauthorized) return } _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) }) server := httptest.NewServer(mux) t.Cleanup(server.Close) original := oauthProviderSet oauthProviderSet = map[string]oauthProvider{ "github": { ID: "github", Name: "GitHub", Endpoint: oauth2.Endpoint{ AuthURL: server.URL + "/authorize", TokenURL: server.URL + "/token", }, Scopes: []string{"user:email"}, FetchProfile: func(ctx context.Context, _ *http.Client, token *oauth2.Token, _ string) (oauthProfile, error) { // Провайдер получает токен так же, как в бою: через oauth2-клиент. authorized := oauth2.NewClient(ctx, oauth2.StaticTokenSource(token)) request, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"/profile", nil) if err != nil { return oauthProfile{}, err } response, err := authorized.Do(request) if err != nil { return oauthProfile{}, err } defer func() { _ = response.Body.Close() }() if response.StatusCode != http.StatusOK { return oauthProfile{}, errors.New("profile request failed") } return profile, nil }, }, } t.Cleanup(func() { oauthProviderSet = original }) return server } func oauthTestService(t *testing.T, cfg config.Config) (*Service, *store.Store) { t.Helper() ctx := context.Background() db, err := database.Open(ctx, database.Options{ Path: filepath.Join(t.TempDir(), "glchat.db"), ReadPool: 2, Migrate: true, }) if err != nil { t.Fatalf("open database: %v", err) } t.Cleanup(func() { _ = db.Close() }) st := store.New(db) service, err := New(ctx, cfg, st, slog.New(slog.DiscardHandler)) if err != nil { t.Fatalf("auth.New: %v", err) } return service, st } // TestOAuthNotConfigured — без клиентских id ручки отвечают понятной ошибкой, // а не пытаются увести пользователя к провайдеру (AGENT.md 7.1). func TestOAuthNotConfigured(t *testing.T) { service, _ := oauthTestService(t, oauthTestBaseConfig()) if service.OAuthEnabled() { t.Fatal("oauth must be disabled without client credentials") } if _, err := service.OAuthAuthorizeURL("github", "/app"); !errors.Is(err, ErrOAuthNotConfigured) { t.Fatalf("authorize url without config: err = %v, want ErrOAuthNotConfigured", err) } if _, err := service.OAuthAuthorizeURL("ok", "/app"); !errors.Is(err, ErrOAuthUnknownProvider) { t.Fatalf("unknown provider: err = %v, want ErrOAuthUnknownProvider", err) } // VK и Яндекс в каталоге есть, но без ключей отвечают «не настроен». for _, provider := range []string{"vk", "yandex"} { if _, err := service.OAuthAuthorizeURL(provider, "/app"); !errors.Is(err, ErrOAuthNotConfigured) { t.Fatalf("%s without config: err = %v, want ErrOAuthNotConfigured", provider, err) } } if len(service.OAuthProviders()) != 0 { t.Fatalf("providers = %v, want empty", service.OAuthProviders()) } } // TestOAuthAuthorizeURL — ссылка ведёт к провайдеру и несёт подписанный state. func TestOAuthAuthorizeURL(t *testing.T) { oauthTestServer(t, oauthProfile{Subject: "1", Email: "user@example.com", EmailVerified: true}) service, _ := oauthTestService(t, oauthTestConfig()) providers := service.OAuthProviders() if len(providers) != 1 || providers[0].ID != "github" { t.Fatalf("providers = %v, want [github]", providers) } authorizeURL, err := service.OAuthAuthorizeURL("github", "/app/invite/abc") if err != nil { t.Fatalf("authorize url: %v", err) } if !strings.HasPrefix(authorizeURL, "http") || !strings.Contains(authorizeURL, "client_id=client-id") { t.Fatalf("unexpected authorize url: %s", authorizeURL) } parsed, err := url.Parse(authorizeURL) if err != nil { t.Fatalf("parse authorize url: %v", err) } state := parsed.Query().Get("state") if state == "" || !strings.Contains(state, ".") { t.Fatalf("state is not signed: %q", state) } redirect, err := service.verifyOAuthState("github", state) if err != nil { t.Fatalf("verify state: %v", err) } if redirect != "/app/invite/abc" { t.Fatalf("redirect = %q", redirect) } // Подмена state и чужой провайдер отклоняются. if _, err := service.verifyOAuthState("google", state); !errors.Is(err, ErrOAuthState) { t.Fatalf("state for another provider must fail, got %v", err) } if _, err := service.verifyOAuthState("github", state+"x"); !errors.Is(err, ErrOAuthState) { t.Fatalf("tampered state must fail, got %v", err) } } // TestOAuthCallbackCreatesAndLinksAccounts — вход новым аккаунтом, привязка к // существующему по email, повторный вход и запрет для забаненного инстанса. func TestOAuthCallbackCreatesAndLinksAccounts(t *testing.T) { oauthTestServer(t, oauthProfile{Subject: "42", Email: "OAuth@Example.com", EmailVerified: true, Login: "Octo Cat"}) service, st := oauthTestService(t, oauthTestConfig()) ctx := context.Background() state, err := service.signOAuthState("github", "/app") if err != nil { t.Fatalf("sign state: %v", err) } user, token, session, redirect, err := service.OAuthCallback(ctx, "github", "good-code", state, url.Values{}, "127.0.0.1", "test-agent") if err != nil { t.Fatalf("oauth callback: %v", err) } if redirect != "/app" || token == "" || session == nil { t.Fatalf("unexpected callback result: redirect=%q token=%q", redirect, token) } if user.Username != "octo_cat" { t.Fatalf("username = %q, want octo_cat", user.Username) } // Email зашифрован, наружу не отдаётся, но расшифровывается сервисом. email, err := service.Email(ctx, user.ID) if err != nil || email != "oauth@example.com" { t.Fatalf("email = %q, err = %v", email, err) } accounts, err := st.ListOAuthAccounts(ctx, user.ID) if err != nil || len(accounts) != 1 { t.Fatalf("oauth accounts = %v, err = %v", accounts, err) } if accounts[0].SubjectIndex == "" || strings.Contains(accounts[0].SubjectIndex, "42") { t.Fatalf("subject must be stored as blind index: %q", accounts[0].SubjectIndex) } // Повторный вход тем же аккаунтом: новой записи не появляется. state, err = service.signOAuthState("github", "/app") if err != nil { t.Fatalf("sign state: %v", err) } again, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", state, url.Values{}, "127.0.0.1", "test-agent") if err != nil { t.Fatalf("second oauth callback: %v", err) } if again.ID != user.ID { t.Fatalf("second login created another account: %d != %d", again.ID, user.ID) } // Бан инстанса запрещает вход через провайдера. if err := st.BanInstanceUser(ctx, user.ID, user.ID, "тест"); err != nil { t.Fatalf("ban user: %v", err) } state, err = service.signOAuthState("github", "/app") if err != nil { t.Fatalf("sign state: %v", err) } if _, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", state, url.Values{}, "127.0.0.1", "test-agent"); !errors.Is(err, ErrUserBanned) { t.Fatalf("banned oauth login: err = %v, want ErrUserBanned", err) } } // TestOAuthCallbackLinksExistingAccountByEmail — провайдер привязывается к // уже существующему аккаунту с тем же подтверждённым email (blind index). func TestOAuthCallbackLinksExistingAccountByEmail(t *testing.T) { oauthTestServer(t, oauthProfile{Subject: "777", Email: "existing@example.com", EmailVerified: true, Login: "existing"}) service, st := oauthTestService(t, oauthTestConfig()) ctx := context.Background() if _, _, _, err := service.Register(ctx, RegisterInput{ Username: "existing", Email: "existing@example.com", Password: "correct-horse-battery", }); err != nil { t.Fatalf("register: %v", err) } existing, err := service.UserByEmail(ctx, "existing@example.com") if err != nil { t.Fatalf("find user: %v", err) } state, err := service.signOAuthState("github", "/app") if err != nil { t.Fatalf("sign state: %v", err) } user, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", state, url.Values{}, "127.0.0.1", "agent") if err != nil { t.Fatalf("oauth callback: %v", err) } if user.ID != existing.ID { t.Fatalf("oauth login must reuse the account: %d != %d", user.ID, existing.ID) } accounts, err := st.ListOAuthAccounts(ctx, user.ID) if err != nil || len(accounts) != 1 { t.Fatalf("link was not created: %v, err = %v", accounts, err) } events, err := st.ListSecurityEvents(ctx, user.ID, 20) if err != nil { t.Fatalf("list security events: %v", err) } found := false for _, event := range events { if event.Type == "oauth_linked" { found = true } } if !found { t.Fatal("security events have no oauth_linked") } } // TestOAuthCallbackRejectsUnverifiedEmail — неподтверждённый email не даёт // войти: иначе чужой адрес в профиле провайдера уводил бы в чужой аккаунт. func TestOAuthCallbackRejectsUnverifiedEmail(t *testing.T) { oauthTestServer(t, oauthProfile{Subject: "5", Email: "unverified@example.com", EmailVerified: false}) service, _ := oauthTestService(t, oauthTestConfig()) state, err := service.signOAuthState("github", "/app") if err != nil { t.Fatalf("sign state: %v", err) } if _, _, _, _, err := service.OAuthCallback(context.Background(), "github", "good-code", state, url.Values{}, "127.0.0.1", "agent"); !errors.Is(err, ErrOAuthEmailUnverified) { t.Fatalf("unverified email: err = %v, want ErrOAuthEmailUnverified", err) } } // TestOAuthCallbackStateAndCodeErrors — неверный state и код отклоняются. func TestOAuthCallbackStateAndCodeErrors(t *testing.T) { oauthTestServer(t, oauthProfile{Subject: "6", Email: "user@example.com", EmailVerified: true}) service, _ := oauthTestService(t, oauthTestConfig()) ctx := context.Background() if _, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", "broken", url.Values{}, "127.0.0.1", "agent"); !errors.Is(err, ErrOAuthState) { t.Fatalf("bad state: err = %v, want ErrOAuthState", err) } state, err := service.signOAuthState("github", "/app") if err != nil { t.Fatalf("sign state: %v", err) } if _, _, _, _, err := service.OAuthCallback(ctx, "github", "", state, url.Values{}, "127.0.0.1", "agent"); !errors.Is(err, ErrOAuthExchange) { t.Fatalf("empty code: err = %v, want ErrOAuthExchange", err) } if _, _, _, _, err := service.OAuthCallback(ctx, "github", "bad-code", state, url.Values{}, "127.0.0.1", "agent"); !errors.Is(err, ErrOAuthExchange) { t.Fatalf("bad code: err = %v, want ErrOAuthExchange", err) } } // TestOAuthRegistrationDisabledBlocksNewAccounts — существующие аккаунты // входят, новые не создаются при выключенной регистрации (7.1). func TestOAuthRegistrationDisabledBlocksNewAccounts(t *testing.T) { oauthTestServer(t, oauthProfile{Subject: "8", Email: "new@example.com", EmailVerified: true, Login: "new"}) service, st := oauthTestService(t, oauthTestConfig()) ctx := context.Background() if err := st.SetInstanceSetting(ctx, "registration_enabled", "false"); err != nil { t.Fatalf("disable registration: %v", err) } state, err := service.signOAuthState("github", "/app") if err != nil { t.Fatalf("sign state: %v", err) } if _, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", state, url.Values{}, "127.0.0.1", "agent"); !errors.Is(err, ErrRegistrationOff) { t.Fatalf("registration disabled: err = %v, want ErrRegistrationOff", err) } } // TestOAuthStateExpires — state старше окна не принимается, даже если подпись // верна: иначе ссылку можно было бы использовать неограниченно долго. func TestOAuthStateExpires(t *testing.T) { service, _ := oauthTestService(t, oauthTestConfig()) payload, err := json.Marshal(map[string]string{ "provider": "github", "redirect": "/app", "issued": strconv.FormatInt(time.Now().UTC().Add(-time.Hour).Unix(), 10), "nonce": "deadbeefdeadbeef", }) if err != nil { t.Fatalf("marshal payload: %v", err) } encoded := base64.RawURLEncoding.EncodeToString(payload) expired := encoded + "." + service.signStatePayload(encoded) if _, err := service.verifyOAuthState("github", expired); !errors.Is(err, ErrOAuthState) { t.Fatalf("expired state: err = %v, want ErrOAuthState", err) } } // TestPasskeyHandleStableAndOpaque — user handle не раскрывает id аккаунта и // стабилен между вызовами. func TestPasskeyHandleStableAndOpaque(t *testing.T) { service, _ := oauthTestService(t, oauthTestBaseConfig()) first := service.passkeyHandle(12345) second := service.passkeyHandle(12345) if len(first) != 16 || !bytes.Equal(first, second) { t.Fatalf("handle must be 16 stable bytes: %v / %v", first, second) } if strings.Contains(string(first), "12345") { t.Fatal("handle must not contain the account id") } if bytes.Equal(service.passkeyHandle(12346), first) { t.Fatal("handles of different accounts must differ") } } // TestPasskeyCeremonyStoreIsOneTime — церемония выдаётся один раз и по TTL. func TestPasskeyCeremonyStoreIsOneTime(t *testing.T) { store := newCeremonyStore() now := time.Now().UTC() store.now = func() time.Time { return now } store.put("token", ceremony{userID: 1, expiresAt: now.Add(time.Minute)}) if _, ok := store.take("token"); !ok { t.Fatal("ceremony must be available once") } if _, ok := store.take("token"); ok { t.Fatal("ceremony must not be reusable") } store.put("expired", ceremony{userID: 1, expiresAt: now.Add(-time.Second)}) if _, ok := store.take("expired"); ok { t.Fatal("expired ceremony must not be usable") } } // TestSanitizeUsername — логин провайдера приводится к правилам glchat. func TestSanitizeUsername(t *testing.T) { cases := map[string]string{ "Octo-Cat": "octo_cat", " John Doe ": "john_doe", "ю": "", "a": "", "user.name": "user.name", "UPPER": "upper", } for input, expected := range cases { if got := sanitizeUsername(input); got != expected { t.Errorf("sanitizeUsername(%q) = %q, want %q", input, got, expected) } } } // TestSafeRedirect — открытый редирект невозможен. func TestSafeRedirect(t *testing.T) { if safeRedirect("//evil.example") != "/app" || safeRedirect("https://evil.example") != "/app" { t.Fatal("external redirects must be rejected") } if safeRedirect("/app/invite/x") != "/app/invite/x" || safeRedirect("") != "/app" { t.Fatal("internal redirects must be preserved") } } // проверяем, что ключ blind index провайдера не совпадает с email-индексом. func TestOAuthSubjectIndexDiffersFromEmail(t *testing.T) { key, err := crypto.ParseMasterKey(oauthTestConfig().MasterKey) if err != nil { t.Fatalf("parse master key: %v", err) } subject := key.BlindIndex("github:42") email := key.BlindIndex("user@example.com") if subject == email || len(subject) != len(email) { t.Fatal("blind indexes must be independent") } } // TestOAuthVKIDFlow — вход через VK ID: PKCE (code_challenge в запросе // авторизации и code_verifier при обмене), device_id из callback и сервисный // ключ отдельным параметром service_token (VK ID не ждёт client_secret), // профиль забирается методом /oauth2/user_info с client_id в теле. func TestOAuthVKIDFlow(t *testing.T) { mux := http.NewServeMux() var tokenForm url.Values mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { w.WriteHeader(http.StatusBadRequest) return } tokenForm = r.PostForm w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "access_token": "vk-access-token", "token_type": "Bearer", "expires_in": 3600, "user_id": 42, }) }) var infoForm url.Values mux.HandleFunc("/user_info", func(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { w.WriteHeader(http.StatusBadRequest) return } infoForm = r.PostForm _ = json.NewEncoder(w).Encode(map[string]any{ "user": map[string]any{ "user_id": 42, "first_name": "Иван", "last_name": "Петров", "email": "ivan@example.com", }, }) }) server := httptest.NewServer(mux) t.Cleanup(server.Close) originalProviders := oauthProviderSet originalInfoURL := vkUserInfoURL providers := defaultOAuthProviders() vk := providers["vk"] vk.Endpoint.AuthURL = server.URL + "/authorize" vk.Endpoint.TokenURL = server.URL + "/token" providers["vk"] = vk oauthProviderSet = providers vkUserInfoURL = server.URL + "/user_info" t.Cleanup(func() { oauthProviderSet = originalProviders vkUserInfoURL = originalInfoURL }) cfg := oauthTestConfig() cfg.OAuthVKClientID = "vk-client" cfg.OAuthVKClientSecret = "vk-service-token" service, _ := oauthTestService(t, cfg) if providers := service.OAuthProviders(); len(providers) != 2 || providers[0].ID != "vk" { t.Fatalf("providers = %v, want vk первым", providers) } authorizeURL, err := service.OAuthAuthorizeURL("vk", "/app/friends") if err != nil { t.Fatalf("authorize url: %v", err) } parsed, err := url.Parse(authorizeURL) if err != nil { t.Fatalf("parse authorize url: %v", err) } query := parsed.Query() if query.Get("code_challenge") == "" || query.Get("code_challenge_method") != "S256" { t.Fatalf("в ссылке нет PKCE: %s", authorizeURL) } if query.Get("client_id") != "vk-client" { t.Fatalf("client_id = %q", query.Get("client_id")) } if !strings.Contains(query.Get("scope"), "email") { t.Fatalf("scope = %q, want email", query.Get("scope")) } state := query.Get("state") if state == "" { t.Fatal("в ссылке нет state") } ctx := context.Background() user, token, session, redirect, err := service.OAuthCallback(ctx, "vk", "vk-code", state, url.Values{"device_id": {"device-123"}}, "127.0.0.1", "agent") if err != nil { t.Fatalf("OAuthCallback: %v", err) } if token == "" || session == nil { t.Fatalf("token=%q session=%v", token, session) } // Email зашифрован: наружу его отдаёт только сервис. email, err := service.Email(ctx, user.ID) if err != nil || email != "ivan@example.com" { t.Fatalf("email = %q, err = %v", email, err) } if redirect != "/app/friends" { t.Fatalf("redirect = %q", redirect) } // Обмен кода: PKCE-верификатор (43 символа), device_id и service_token. if len(tokenForm.Get("code_verifier")) != 43 { t.Fatalf("code_verifier = %q (len %d), want 43", tokenForm.Get("code_verifier"), len(tokenForm.Get("code_verifier"))) } if tokenForm.Get("device_id") != "device-123" { t.Fatalf("device_id = %q", tokenForm.Get("device_id")) } if tokenForm.Get("service_token") != "vk-service-token" { t.Fatalf("service_token = %q", tokenForm.Get("service_token")) } if tokenForm.Get("client_secret") != "" { t.Fatalf("client_secret не должен уходить в VK: %q", tokenForm.Get("client_secret")) } if tokenForm.Get("state") != state { t.Fatalf("state в обмене = %q, want %q", tokenForm.Get("state"), state) } // Профиль: POST с client_id и токеном. if infoForm.Get("client_id") != "vk-client" || infoForm.Get("access_token") != "vk-access-token" { t.Fatalf("user_info form = %v", infoForm) } } // TestOAuthYandexFlow — вход через Яндекс ID: обмен кода с client_id/secret в // теле запроса и профиль по заголовку `Authorization: OAuth <токен>`. func TestOAuthYandexFlow(t *testing.T) { mux := http.NewServeMux() var infoAuth string mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { w.WriteHeader(http.StatusBadRequest) return } if r.PostForm.Get("client_id") != "ya-client" || r.PostForm.Get("client_secret") != "ya-secret" { w.WriteHeader(http.StatusUnauthorized) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "access_token": "ya-access-token", "token_type": "bearer", "expires_in": 3600, }) }) mux.HandleFunc("/info", func(w http.ResponseWriter, r *http.Request) { infoAuth = r.Header.Get("Authorization") _ = json.NewEncoder(w).Encode(map[string]any{ "id": "1000034426", "login": "ivan", "default_email": "ivan@yandex.ru", "emails": []string{"ivan@yandex.ru"}, "real_name": "Иван Петров", }) }) server := httptest.NewServer(mux) t.Cleanup(server.Close) originalProviders := oauthProviderSet originalInfoURL := yandexInfoURL providers := defaultOAuthProviders() yandex := providers["yandex"] yandex.Endpoint.AuthURL = server.URL + "/authorize" yandex.Endpoint.TokenURL = server.URL + "/token" providers["yandex"] = yandex oauthProviderSet = providers yandexInfoURL = server.URL + "/info" t.Cleanup(func() { oauthProviderSet = originalProviders yandexInfoURL = originalInfoURL }) cfg := oauthTestConfig() cfg.OAuthYandexClientID = "ya-client" cfg.OAuthYandexClientSecret = "ya-secret" service, _ := oauthTestService(t, cfg) authorizeURL, err := service.OAuthAuthorizeURL("yandex", "/app") if err != nil { t.Fatalf("authorize url: %v", err) } parsed, err := url.Parse(authorizeURL) if err != nil { t.Fatalf("parse authorize url: %v", err) } if parsed.Query().Get("code_challenge") != "" { t.Fatal("Яндекс не требует PKCE: code_challenge не нужен") } state := parsed.Query().Get("state") ctx := context.Background() user, _, _, _, err := service.OAuthCallback(ctx, "yandex", "ya-code", state, url.Values{}, "127.0.0.1", "agent") if err != nil { t.Fatalf("OAuthCallback: %v", err) } email, err := service.Email(ctx, user.ID) if err != nil || email != "ivan@yandex.ru" { t.Fatalf("email = %q, err = %v", email, err) } if user.Username != "ivan" { t.Fatalf("username = %q, want логин Яндекса", user.Username) } if infoAuth != "OAuth ya-access-token" { t.Fatalf("Authorization = %q, want `OAuth …`", infoAuth) } }