package lk import ( "encoding/base64" "encoding/json" "fmt" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "strings" "testing" "time" ) // fakeBJ — API bj-server для кабинета: проверка ключа, вход и портфель. func fakeBJ(t *testing.T) *httptest.Server { t.Helper() mux := http.NewServeMux() keyOK := func(w http.ResponseWriter, r *http.Request) bool { if r.Header.Get("X-BJ-Key") != "lk-key" { w.WriteHeader(http.StatusForbidden) _ = json.NewEncoder(w).Encode(map[string]string{"error": "неверный ключ модуля"}) return false } return true } mux.HandleFunc("/api/lk/login", func(w http.ResponseWriter, r *http.Request) { if keyOK(w, r) { _ = json.NewEncoder(w).Encode(map[string]any{"session": "sess-1", "full_name": "Мостовиков Тест", "is_admin": false}) } }) mux.HandleFunc("/api/lk/portfolio", func(w http.ResponseWriter, r *http.Request) { if keyOK(w, r) && r.Header.Get("X-BJ-Session") == "sess-1" { _ = json.NewEncoder(w).Encode(map[string]any{"full_name": "Мостовиков Тест", "contract": "4519", "source": "депозитарный учёт", "total_value": 110268.0, "priced": 1, "email": "client@example.org", "phone": "+7 900 000-00-00", "last_login": "17.09.2026 21:58", "positions": []any{ map[string]any{"isin": "RU0009029540", "security_name": "Сбербанк, ао", "account_id": "ВТНКЦ00000001", "quantity": 400, "blocked": 100, "available": 300, "transferable": true, "updated_at": "17.09.2026 21:58", "price": 275.67, "value": 110268, "change_pct": -1.53}, map[string]any{"isin": "US0378331005", "security_name": "Apple Inc.", "account_id": "В000000044", "quantity": 12, "blocked": 0, "available": 0, "transferable": false, "note": "иностранная бумага — перевод через M2M недоступен"}, }}) } }) mux.HandleFunc("/api/lk/claims", func(w http.ResponseWriter, r *http.Request) { if keyOK(w, r) && r.Header.Get("X-BJ-Session") == "sess-1" { _ = json.NewEncoder(w).Encode(map[string]any{"claims": []any{ map[string]any{"guid": "41523F7F-1111", "status": "sent", "status_ru": "Отправлено в НРД", "created_at": "16.09.2026", "securities": 1}, map[string]any{"guid": "D744A410-2222", "status": "done", "status_ru": "Завершена", "created_at": "09.09.2026", "securities": 2}, }}) } }) mux.HandleFunc("/api/lk/history", func(w http.ResponseWriter, r *http.Request) { if keyOK(w, r) && r.Header.Get("X-BJ-Session") == "sess-1" { _ = json.NewEncoder(w).Encode(map[string]any{"on": true, "at": "20:30", "points": []any{ map[string]any{"day": "2026-09-16", "value": 100000.0, "cash": 200.0, "total": 100200.0}, map[string]any{"day": "2026-09-17", "value": 104000.0, "cash": 200.0, "total": 104200.0}, map[string]any{"day": "2026-09-18", "value": 110068.0, "cash": 200.0, "total": 110268.0}, }}) } }) mux.HandleFunc("/api/lk/admin/clients", func(w http.ResponseWriter, r *http.Request) { if !keyOK(w, r) || r.Header.Get("X-BJ-Session") != "sess-1" { return } search := r.URL.Query().Get("search") clients := []any{map[string]any{"login": "bublikov", "contract_number": "БР-719", "full_name": "Аминов Вячеслав Маркович", "active": true}} depo := []any{map[string]any{"contract": "БР-719 Аминов Вячеслав Маркович", "name": "Аминов Вячеслав Маркович", "securities": 14}} if search != "" && !strings.Contains(strings.ToLower("БР-719 Аминов Вячеслав Маркович"), strings.ToLower(search)) { clients, depo = nil, nil } _ = json.NewEncoder(w).Encode(map[string]any{"clients": clients, "depo": depo, "search": search, "suggest": []string{"БР-719 Аминов Вячеслав Маркович", "БР-746 Бубликов Иван Петрович"}}) }) mux.HandleFunc("/api/lk/security", func(w http.ResponseWriter, r *http.Request) { if keyOK(w, r) && r.Header.Get("X-BJ-Session") == "sess-1" { _ = json.NewEncoder(w).Encode(map[string]any{"security": map[string]any{ "isin": "RU0009029540", "name": "Сбербанк РФ-АО3", "kind": "акция", "issuer": "ПАО Сбербанк", "reg_number": "10301481B", "cfi": "ESVXFR", "face": 3, "accepted": true, "price": 275.67, "price_in": "₽ за штуку", "board": "TQBR", "change_pct": -1.53, "position": map[string]any{"isin": "RU0009029540", "quantity": 400, "available": 300, "value": 110268, "transferable": true}, "payments": []any{map[string]any{"date": "18.10.2026", "kind": "дивиденды", "amount": 34.5}}, }}) } }) mux.HandleFunc("/api/lk/operations", func(w http.ResponseWriter, r *http.Request) { if keyOK(w, r) && r.Header.Get("X-BJ-Session") == "sess-1" { _ = json.NewEncoder(w).Encode(map[string]any{"operations": []any{ map[string]any{"date": "16.09.2026", "kind": "зачисление", "number": "260915/1", "account": "ВТНКЦ00000001", "isin": "RU0009029540", "security": "Сбербанк, ао", "quantity": 100, "state": "исполнено", "note": "Отчёт клиринговой организации"}, map[string]any{"date": "14.08.2026", "kind": "списание", "account": "ВТНКЦ00000001", "isin": "RU000A1014L8", "security": "Ликвидность, пай", "quantity": -120, "state": "исполнено"}, }}) } }) mux.HandleFunc("/api/lk/income", func(w http.ResponseWriter, r *http.Request) { if keyOK(w, r) && r.Header.Get("X-BJ-Session") == "sess-1" { _ = json.NewEncoder(w).Encode(map[string]any{"accrued": 540.6, "paid": 494.6, "tax": 46.0, "income": []any{ map[string]any{"date": "02.06.2026", "kind": "дивиденды", "isin": "RU000A106YF0", "security": "ВК, ао", "quantity": 5, "accrued": 45, "tax": 6, "paid": 39}, map[string]any{"date": "03.06.2026", "kind": "купон", "isin": "RU000A1038V6", "security": "ОФЗ 26238", "accrued": 495.6, "tax": 0, "paid": 495.6}, }}) } }) mux.HandleFunc("/api/lk/ping", func(w http.ResponseWriter, r *http.Request) { if keyOK(w, r) { _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) } }) mux.HandleFunc("/api/lk/portfolio2", func(w http.ResponseWriter, r *http.Request) { if !keyOK(w, r) { return } view := r.URL.Query().Get("view") asOf := "18.09.2026" if view == "archive" { asOf = "31.12.2025" } _ = json.NewEncoder(w).Encode(map[string]any{"contract": "BR-1", "full_name": "Мостовиков Тест", "portfolio": map[string]any{ "source": map[bool]string{true: "spectre", false: "quik"}[view == "archive"], "as_of": asOf, "total": 110268.0, "securities_value": 92698.28, "cash_rub": 17569.72, "cost": 80000.0, "pnl": 12698.28, "pnl_pct": 15.87, "priced": 1, "cash": []map[string]any{{"currency": "SUR", "amount": 17569.72}}, "positions": []map[string]any{{"isin": "RU0009029540", "name": "Сбербанк, ао", "kind": "акция", "quantity": 336, "avg_price": 238.1, "price": 275.887, "value": 92698.28, "cost": 80000, "pnl": 12698.28, "pnl_pct": 15.87, "day_pct": 1.2, "share": 84.1}}, }, "upcoming": []map[string]any{{"date": "10.10.2026", "isin": "RU0009029540", "security": "Сбербанк, ао", "kind": "дивиденды", "quantity": 336}}}) }) mux.HandleFunc("/api/lk/ops", func(w http.ResponseWriter, r *http.Request) { if !keyOK(w, r) { return } _ = json.NewEncoder(w).Encode(map[string]any{"from": "2025-09-18", "to": "2026-09-18", "operations": []map[string]any{ {"date": "10.07.2026", "kind": "продажа", "side": "out", "name": "НОВАТЭК 001P-03", "isin": "RU000A10AUX8", "quantity": 1700, "price": 7793.07, "amount": 13248214.78, "currency": "SUR", "amount_rub": 13248214.78}, {"date": "06.03.2026", "kind": "покупка", "side": "in", "name": "Сбербанк, ао", "isin": "RU0009029540", "quantity": 100, "price": 238.1, "amount": 23810, "currency": "SUR", "amount_rub": 23810}, {"date": "12.05.2026", "kind": "дивиденды", "side": "in", "name": "Сбербанк, ао", "isin": "RU0009029540", "quantity": 336, "amount": 494.6, "currency": "SUR", "amount_rub": 494.6}, }}) }) mux.HandleFunc("/api/lk/admin/bootstrap", func(w http.ResponseWriter, r *http.Request) { if !keyOK(w, r) { return } var in struct{ Login, Password string } _ = json.NewDecoder(r.Body).Decode(&in) if in.Login == "" || len(in.Password) < 10 { w.WriteHeader(http.StatusBadRequest) _ = json.NewEncoder(w).Encode(map[string]string{"error": "пароль минимум 10 символов"}) return } _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "message": "администратор создан"}) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) return srv } func TestLoginPortfolioHealth(t *testing.T) { bj := fakeBJ(t) s, err := New(Config{BJURL: bj.URL + "/", BJKey: "lk-key", Title: "Кабинет · тест"}) if err != nil { t.Fatal(err) } h := s.Handler() // Форма входа подписана токеном CSRF: без него запрос не выполняется. csrf := csrfFromPage(t, h) form := url.Values{"login": {"client"}, "password": {"secret"}, "csrf": {csrf.Value}} req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(csrf) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" { t.Fatalf("вход: код %d, Location %q", rec.Code, rec.Header().Get("Location")) } var sess *http.Cookie for _, c := range rec.Result().Cookies() { if c.Name == "bj_lk" { sess = c } } if sess == nil || sess.Value != "sess-1" || !sess.HttpOnly { t.Fatalf("кука сессии: %+v", sess) } req = httptest.NewRequest(http.MethodGet, "/", nil) req.AddCookie(sess) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) body := rec.Body.String() if rec.Code != http.StatusOK || !strings.Contains(body, "Мостовиков Тест") || !strings.Contains(body, "Кабинет · тест") { t.Fatalf("портфель: код %d: %s", rec.Code, body) } if strings.Contains(body, `href="/clients"`) { t.Error("клиенту без признака администратора показан раздел «Клиенты»") } rec = httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/health", nil)) if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"ok":true`) { t.Fatalf("health: код %d: %s", rec.Code, rec.Body.String()) } } // Признак администратора берётся из кук своего запроса, а не предыдущего (прежде был общий curReq). func TestAdminFlagPerRequest(t *testing.T) { bj := fakeBJ(t) s, err := New(Config{BJURL: bj.URL, BJKey: "lk-key"}) if err != nil { t.Fatal(err) } h := s.Handler() admin := httptest.NewRequest(http.MethodGet, "/login", nil) admin.AddCookie(&http.Cookie{Name: "bj_lk_admin", Value: "1"}) h.ServeHTTP(httptest.NewRecorder(), admin) req := httptest.NewRequest(http.MethodGet, "/", nil) req.AddCookie(&http.Cookie{Name: "bj_lk", Value: "sess-1"}) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if strings.Contains(rec.Body.String(), `href="/clients"`) { t.Fatal("признак администратора перешёл из чужого запроса") } } // Кабинет без настроек ведёт в веб-установщик, а /health честно отвечает, что связи нет. func TestNotConfigured(t *testing.T) { s, err := New(Config{BJURL: "http://127.0.0.1:8080"}) if err != nil { t.Fatal(err) } s.SetDataDir(t.TempDir()) if s.Configured() { t.Fatal("без ключа кабинет не настроен") } h := s.Handler() rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/install" { t.Fatalf("/: код %d, Location %q — ожидали установщик", rec.Code, rec.Header().Get("Location")) } rec = httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/install", nil)) if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Проверить связь и сохранить") { t.Fatalf("/install: код %d", rec.Code) } rec = httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/health", nil)) if rec.Code != http.StatusBadGateway { t.Fatalf("/health без настроек: код %d", rec.Code) } } // Веб-установщик целиком: проверка сервера → связь с ядром → администратор → файл настроек. func TestInstaller(t *testing.T) { bj := fakeBJ(t) dir := t.TempDir() s, err := New(Config{}) if err != nil { t.Fatal(err) } s.SetDataDir(dir) h := s.Handler() csrf := csrfFromPage(t, h) post := func(form url.Values) *httptest.ResponseRecorder { form.Set("csrf", csrf.Value) req := httptest.NewRequest(http.MethodPost, "/install", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(csrf) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } // неверный ключ — настройки не сохраняются rec := post(url.Values{"step": {"connect"}, "mode": {"direct"}, "bj_url": {bj.URL}, "bj_key": {"чужой"}}) if !strings.Contains(rec.Body.String(), "Связь с ядром не установлена") || s.Configured() { t.Fatalf("неверный ключ принят: %d", rec.Code) } // верный ключ — сохранено, шаг администратора rec = post(url.Values{"step": {"connect"}, "mode": {"direct"}, "bj_url": {bj.URL}, "bj_key": {"lk-key"}, "title": {"Тест"}}) if !strings.Contains(rec.Body.String(), "Администратор кабинета") || !s.Configured() { t.Fatalf("связь не сохранена: %d %s", rec.Code, rec.Body.String()[:200]) } raw, err := os.ReadFile(filepath.Join(dir, configFileName)) if err != nil || !strings.Contains(string(raw), `"bj_key": "lk-key"`) || !strings.Contains(string(raw), `"mode": "direct"`) { t.Fatalf("файл настроек: %v %s", err, raw) } if st, _ := os.Stat(filepath.Join(dir, configFileName)); st.Mode().Perm() != 0o600 { t.Fatalf("права файла настроек %o", st.Mode().Perm()) } // администратор: пароли не совпали → ошибка; совпали → ядро получило bootstrap rec = post(url.Values{"step": {"admin"}, "login": {"admin"}, "password": {"Надёжный-пароль-1"}, "password2": {"другой"}}) if !strings.Contains(rec.Body.String(), "Пароли не совпадают") { t.Fatal("несовпадение паролей пропущено") } rec = post(url.Values{"step": {"admin"}, "login": {"admin"}, "password": {"Надёжный-пароль-1"}, "password2": {"Надёжный-пароль-1"}}) if !strings.Contains(rec.Body.String(), "Кабинет установлен") { t.Fatalf("завершение: %d %s", rec.Code, rec.Body.String()[:200]) } // установщик закрыт, настройки поднимаются из файла при новом запуске rec = httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/install", nil)) if !strings.Contains(rec.Body.String(), "уже настроен") { t.Fatal("установщик остался открытым после установки") } cfg, err := LoadConfigFile(dir, Config{}) if err != nil || cfg.BJKey != "lk-key" || cfg.BJURL != bj.URL || cfg.Agent || cfg.Title != "Тест" { t.Fatalf("настройки из файла: %+v %v", cfg, err) } // окружение перекрывает файл cfg, _ = LoadConfigFile(dir, Config{BJKey: "из-окружения"}) if cfg.BJKey != "из-окружения" { t.Fatal("окружение должно перекрывать файл") } } // Страницы кабинета в оформлении «Табло»: обзор, реестр, поручения, профиль и бланк поручения. func TestBoardPages(t *testing.T) { bj := fakeBJ(t) s, err := New(Config{BJURL: bj.URL, BJKey: "lk-key", Title: "Кабинет"}) if err != nil { t.Fatal(err) } h := s.Handler() get := func(path string) string { req := httptest.NewRequest(http.MethodGet, path, nil) req.AddCookie(&http.Cookie{Name: "bj_lk", Value: "sess-1"}) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("%s: код %d", path, rec.Code) } return rec.Body.String() } board := get("/") for _, want := range []string{"Стоимость портфеля", "Новое поручение", "Сбербанк, ао", "Онлайн", "Архив", "12 698,28 ₽", "15,87 %", "84,1%", "Структура портфеля", "деньги", "Ближайшие выплаты", "по решению эмитента"} { if !strings.Contains(board, want) { t.Errorf("в портфеле нет %q", want) if i := strings.Index(board, "Результат"); i > 0 { t.Logf("фрагмент: %s", board[i:min(i+400, len(board))]) } } } if !strings.Contains(board, "110 268 ₽") { t.Errorf("стоимость портфеля не показана: %s", board) } // Рубли видны в шапке и в структуре — отдельным блоком их не повторяем. if strings.Contains(board, "Денежные средства") { t.Error("для одних рублей блок «Денежные средства» дублирует структуру") } arch := get("/?view=archive&date=2025-12-31") if !strings.Contains(arch, "на 31.12.2025") || !strings.Contains(arch, `value="2025-12-31"`) { t.Errorf("архив на дату не показан") } // старые адреса ведут на новые разделы rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/portfolio", nil)) if rec.Code != http.StatusMovedPermanently || rec.Header().Get("Location") != "/" { t.Errorf("/portfolio должен вести на портфель: %d %s", rec.Code, rec.Header().Get("Location")) } rec = httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/income", nil)) if rec.Code != http.StatusMovedPermanently || rec.Header().Get("Location") != "/operations" { t.Errorf("/income должен вести на операции: %d %s", rec.Code, rec.Header().Get("Location")) } orders := get("/orders") if !strings.Contains(orders, "Поручения депозитарию") || !strings.Contains(orders, "Перевод в другой депозитарий") { t.Errorf("журнал поручений: %s", orders) } order := get("/order?isin=RU0009029540") for _, want := range []string{"Поручение депозитарию", "Вид поручения", "ИНН принимающего депозитария", `value="RU0009029540" id="cRU0009029540" checked`} { if !strings.Contains(order, want) { t.Errorf("в бланке поручения нет %q", want) } } card := get("/security?isin=RU0009029540") for _, want := range []string{"ПАО Сбербанк", "275,67", "дивиденды", "Перевести эту бумагу", "← Портфель"} { if !strings.Contains(card, want) { t.Errorf("в карточке бумаги нет %q", want) } } ops := get("/operations") for _, want := range []string{"Операции", "продажа", "покупка", "дивиденды", "НОВАТЭК 001P-03", "−13 248 214,78 ₽", "+494,60 ₽", "Продажи"} { if !strings.Contains(ops, want) { t.Errorf("в операциях нет %q", want) } } prof := get("/profile") if !strings.Contains(prof, "Счета депо") || !strings.Contains(prof, "ВТНКЦ00000001") { t.Errorf("профиль: %s", prof) } // старые адреса продолжают работать for path, to := range map[string]string{"/transfer": "/order", "/claims": "/orders"} { req := httptest.NewRequest(http.MethodGet, path, nil) req.AddCookie(&http.Cookie{Name: "bj_lk", Value: "sess-1"}) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusMovedPermanently || rec.Header().Get("Location") != to { t.Errorf("%s → %s: код %d, Location %q", path, to, rec.Code, rec.Header().Get("Location")) } } } // csrfFromPage — открыть страницу входа и взять с неё токен формы. func csrfFromPage(t *testing.T, h http.Handler) *http.Cookie { t.Helper() rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login", nil)) if loc := rec.Header().Get("Location"); rec.Code == http.StatusSeeOther && loc != "" { rec = httptest.NewRecorder() // кабинет не настроен: токен выдаёт установщик h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, loc, nil)) } for _, c := range rec.Result().Cookies() { if c.Name == csrfCookie { return c } } t.Fatal("страница входа не выдала токен формы") return nil } // TestSecurityHeaders — кабинет отдаёт заголовки защиты и не разрешает кэшировать страницы с данными. func TestSecurityHeaders(t *testing.T) { bj := fakeBJ(t) s, err := New(Config{BJURL: bj.URL, BJKey: "lk-key"}) if err != nil { t.Fatal(err) } rec := httptest.NewRecorder() s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login", nil)) for h, want := range map[string]string{ "X-Frame-Options": "DENY", "X-Content-Type-Options": "nosniff", "Referrer-Policy": "same-origin", } { if got := rec.Header().Get(h); got != want { t.Errorf("%s = %q, ожидали %q", h, got, want) } } if csp := rec.Header().Get("Content-Security-Policy"); !strings.Contains(csp, "frame-ancestors 'none'") { t.Errorf("CSP без запрета встраивания: %q", csp) } if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "no-store") { t.Errorf("Cache-Control = %q", cc) } if body := rec.Body.String(); strings.Contains(body, "fonts.googleapis.com") || strings.Contains(body, "fonts.gstatic.com") { t.Error("страница тянет шрифты со стороннего сайта") } } // TestImpersonateNeedsPost — вход сотрудника в кабинет клиента не выполняется по ссылке. func TestImpersonateNeedsPost(t *testing.T) { bj := fakeBJ(t) s, err := New(Config{BJURL: bj.URL, BJKey: "lk-key"}) if err != nil { t.Fatal(err) } h := s.Handler() req := httptest.NewRequest(http.MethodGet, "/impersonate?contract=BR-1", nil) req.AddCookie(&http.Cookie{Name: "bj_lk", Value: "sess-1"}) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if loc := rec.Header().Get("Location"); loc != "/clients" { t.Fatalf("GET /impersonate: код %d, Location %q — должен отправлять к списку клиентов без переключения", rec.Code, loc) } for _, c := range rec.Result().Cookies() { if c.Name == "bj_lk" && c.Value != "" && c.Value != "sess-1" { t.Fatal("GET /impersonate переключил сессию") } } } // TestAgentMode — режим ожидания: запрос страницы попадает в очередь, ядро забирает // его своим подключением и возвращает ответ. func TestAgentMode(t *testing.T) { s, err := New(Config{BJKey: "lk-key", Agent: true}) if err != nil { t.Fatal(err) } if !s.Configured() { t.Fatal("в режиме ожидания адрес ядра не нужен") } h := s.Handler() // «ядро»: подключается к кабинету, забирает запрос и отвечает done := make(chan string, 1) go func() { for i := 0; i < 50; i++ { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/agent/tasks", nil) req.Header.Set("X-BJ-Key", "lk-key") h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { time.Sleep(10 * time.Millisecond) continue } var task struct{ ID, Method, Path string } if json.Unmarshal(rec.Body.Bytes(), &task) != nil { continue } done <- task.Path body := base64.StdEncoding.EncodeToString([]byte(`{"session":"sess-1","full_name":"Клиент","contract":"BR-1"}`)) payload := fmt.Sprintf(`{"id":%q,"status":200,"body":%q}`, task.ID, body) rr := httptest.NewRecorder() rq := httptest.NewRequest(http.MethodPost, "/agent/result", strings.NewReader(payload)) rq.Header.Set("X-BJ-Key", "lk-key") h.ServeHTTP(rr, rq) return } }() csrf := csrfFromPage(t, h) form := url.Values{"login": {"client"}, "password": {"secret"}, "csrf": {csrf.Value}} req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(csrf) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusSeeOther { t.Fatalf("вход через очередь: код %d, тело %s", rec.Code, rec.Body.String()) } select { case path := <-done: if path != "/api/lk/login" { t.Fatalf("ядро получило %q", path) } default: t.Fatal("ядро не получило запрос") } // чужой ключ к каналу не подпускаем rec = httptest.NewRecorder() bad := httptest.NewRequest(http.MethodGet, "/agent/tasks", nil) bad.Header.Set("X-BJ-Key", "не тот ключ") h.ServeHTTP(rec, bad) if rec.Code != http.StatusForbidden { t.Fatalf("чужой ключ принят: код %d", rec.Code) } } // Поиск клиентов в режиме сотрудника: подсказки списком и результат под формой. func TestClientsSearch(t *testing.T) { bj := fakeBJ(t) s, err := New(Config{BJURL: bj.URL, BJKey: "lk-key", Title: "Кабинет"}) if err != nil { t.Fatal(err) } h := s.Handler() get := func(path string) string { req := httptest.NewRequest(http.MethodGet, path, nil) req.AddCookie(&http.Cookie{Name: "bj_lk", Value: "sess-1"}) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("%s: код %d", path, rec.Code) } return rec.Body.String() } page := get("/clients") for _, want := range []string{`list="cl-suggest"`, ``, ``, "Найти"} { if !strings.Contains(page, want) { t.Errorf("на странице клиентов нет %q", want) } } found := get("/clients?search=" + url.QueryEscape("Аминов")) if !strings.Contains(found, "найдено: 2") { t.Errorf("не показан итог поиска: %s", found) } if !strings.Contains(found, "БР-719 Аминов Вячеслав Маркович") { t.Error("найденный договор не выведен списком") } empty := get("/clients?search=" + url.QueryEscape("Петров")) if !strings.Contains(empty, "договоров не найдено") { t.Errorf("пустой результат не объяснён: %s", empty) } } // График стоимости и контакты в профиле. func TestChartAndProfile(t *testing.T) { bj := fakeBJ(t) s, err := New(Config{BJURL: bj.URL, BJKey: "lk-key", Title: "Кабинет"}) if err != nil { t.Fatal(err) } h := s.Handler() get := func(path string) string { req := httptest.NewRequest(http.MethodGet, path, nil) req.AddCookie(&http.Cookie{Name: "bj_lk", Value: "sess-1"}) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("%s: код %d", path, rec.Code) } return rec.Body.String() } board := get("/") for _, want := range []string{"Стоимость портфеля", "