Files
Bridge-and-Join-s/modules/lk/lk_test.go
T
zuevav d975957335 Безопасность: fail-closed auth (H1), доверенный прокси для IP (H2), CSRF на /order (M2)
По результатам security-аудита (docs, run-1). Три верифицированные находки.

H1 (HIGH) — fail-open аутентификация. loadUsers() при ошибке чтения/парсинга
users.json молча возвращал пустой стор → authOpen() считал это первичной
настройкой → после удаления first-run файла весь /admin отдавался без входа.
Теперь: постоянный маркер configured (пишется при создании первого админа и
бэкфиллится при загрузке валидного стора); authOpen() открыт только на реально
свежей системе; при пустом/битом сторе на настроенной системе — 503 и блокировка
до восстановления файла (authLockedOut). Тест TestAuthFailClosed (4 сценария).

H2 (HIGH) — интернет-слой доверял клиентскому X-Forwarded-For/X-Real-IP, что
позволяло обойти лимит входа по IP и травить аудит. Теперь заголовкам доверяем
только от доверенного прокси (по умолчанию loopback; расширяется
BJ_TRUSTED_PROXIES), из XFF берём правый элемент. Исправлено и в Go-модуле
(modules/lk/security.go), и в PHP-ретрансляторе. Тесты TestClientIPTrust (Go) и
прогон логики PHP.

M2 (MEDIUM) — POST /order (поручение на списание бумаг) не проверял CSRF, в
отличие от /login и /impersonate. Добавлен csrfOK (форма уже несла токен).
Тест TestOrderCSRF.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL
2026-09-18 21:25:35 +03:00

640 lines
30 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"`, `<datalist id="cl-suggest">`,
`<option value="БР-746 Бубликов Иван Петрович"></option>`, "Найти"} {
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{"Стоимость портфеля", "<svg viewBox=", "срезов: 3", "16.09"} {
if !strings.Contains(board, want) {
t.Errorf("на графике нет %q", want)
}
}
// В архиве график не показываем: там стоимость на выбранную дату.
if strings.Contains(get("/?view=archive&date=2025-12-31"), `class="sec chart"`) {
t.Error("в архиве график лишний")
}
prof := get("/profile")
for _, want := range []string{"Контакты в анкете", "client@example.org", "900 000-00-00", "Прошлый вход"} {
if !strings.Contains(prof, want) {
t.Errorf("в профиле нет %q", want)
}
}
// Заказ документов убран: формы и обработчика больше нет.
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/documents", nil)
req.AddCookie(&http.Cookie{Name: "bj_lk", Value: "sess-1"})
h.ServeHTTP(rec, req)
if rec.Code == http.StatusSeeOther {
t.Error("POST /documents всё ещё принимает заказ документа")
}
}
// M2: POST /order без валидного CSRF-токена не должен уходить в ядро.
func TestOrderCSRF(t *testing.T) {
var transferCalled bool
bj := fakeBJ(t)
// добавим перехват transfer поверх fakeBJ нельзя (mux фиксирован) — проверяем по редиректу.
s, err := New(Config{BJURL: bj.URL, BJKey: "lk-key", Title: "Кабинет"})
if err != nil {
t.Fatal(err)
}
h := s.Handler()
// без csrf-куки и поля — ожидаем редирект на /order, поручение не подаётся
form := url.Values{"isin": {"RU0009029540"}, "qty_RU0009029540": {"10"}, "receiving_inn": {"7700000000"}}
req := httptest.NewRequest(http.MethodPost, "/order", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: "bj_lk", Value: "sess-1"})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/order" {
t.Errorf("POST /order без CSRF: ожидали редирект на /order, got %d %s", rec.Code, rec.Header().Get("Location"))
}
_ = transferCalled
}