package lk import ( "encoding/json" "net/http" "net/http/httptest" "net/url" "strings" "testing" ) // 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, "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/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}) } }) 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() form := url.Values{"login": {"client"}, "password": {"secret"}} req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 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("признак администратора перешёл из чужого запроса") } } func TestNotConfigured(t *testing.T) { s, err := New(Config{BJURL: "http://127.0.0.1:8080"}) if err != nil { t.Fatal(err) } if s.Configured() { t.Fatal("без ключа кабинет не настроен") } for _, p := range []string{"/", "/health"} { rec := httptest.NewRecorder() s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, p, nil)) if rec.Code != http.StatusServiceUnavailable || !strings.Contains(rec.Body.String(), "BJ_KEY") { t.Fatalf("%s: код %d: %s", p, rec.Code, rec.Body.String()) } } } // Страницы кабинета в оформлении «Табло»: обзор, реестр, поручения, профиль и бланк поручения. 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{"Стоимость бумаг", "Новое поручение", "Сбербанк, ао", "Отправлено в НРД", "Поручений в работе"} { if !strings.Contains(board, want) { t.Errorf("на табло нет %q", want) } } if !strings.Contains(board, "110 268 ₽") { t.Errorf("на табло нет оценки портфеля") } reg := get("/portfolio") for _, want := range []string{"ВТНКЦ00000001", "иностранный выпуск", "депозитарный учёт", "перевести"} { if !strings.Contains(reg, want) { t.Errorf("в реестре нет %q", want) } } 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{"ПАО Сбербанк", "Код CFI", "275,67", "дивиденды", "Перевести эту бумагу"} { if !strings.Contains(card, want) { t.Errorf("в карточке бумаги нет %q", want) } } ops := get("/operations") for _, want := range []string{"Операции по счетам депо", "зачисление", "Ликвидность, пай", "Отчёт клиринговой организации"} { if !strings.Contains(ops, want) { t.Errorf("в операциях нет %q", want) } } inc := get("/income") for _, want := range []string{"Выплачено дохода", "494,60 ₽", "Удержан налог", "дивиденды"} { if !strings.Contains(inc, 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")) } } }