Files
Bridge-and-Join-s/modules/lk/lk_test.go
T
zuevav 2a7442c935 Кабинет клиента: документы и корпоративные действия, дистрибутив, обратный канал, защита
Данные
- корпоративные действия по бумагам клиента (U_CA_ACTS), готовые выписки (U_OP_INF_EXTRACT),
  заявки клиента на документ (lk.doc_requests) с оповещением сотрудникам; страница «Документы»
- дистрибутив кабинета из настроек ядра: архив с программой, службой, настройками,
  установщиком и инструкцией; ключ доступа на странице показан замаскированным

Связь без публичного адреса у ядра
- режим ожидания (BJ_MODE=agent): кабинет копит запросы, ядро само подключается к нему,
  выполняет их обработчиком /api/lk/* и возвращает ответы; 4 параллельных подключения,
  тот же ключ модуля в обе стороны, состояние канала в настройках
- сквозной тест: настоящий кабинет + настоящий цикл ядра

Защита (по итогам аудита)
- вход: лимит попыток по адресу и логину, одинаковое время ответа для несуществующего логина,
  пароль от 10 символов
- сессии ядра: на диске отпечаток токена и ссылка на учётную запись, без персональных данных;
  карточка клиента читается из БД на каждый запрос (блокировка действует сразу); абсолютный
  срок 24 ч; привязка к браузеру, к сети — по настройке
- вход сотрудника в кабинет клиента пишется в lk.audit и поднимает оповещение
- кабинет: CSRF на всех формах, /impersonate только POST, куки HttpOnly+Secure+SameSite=Strict,
  выход гасит все сессии, заголовки CSP/X-Frame-Options/nosniff/HSTS, no-store, таймауты сервера,
  ограничение тела, recover; сообщения через куку вместо адреса; /health наружу без подробностей
- шрифты вшиты в программу: страница не обращается к сторонним сайтам
- экранирование LIKE в выборках по номеру договора; ключ и токен без суррогата при отказе rand
- исправлено: ошибка входа не показывалась клиенту (шаблон ждал Error, приходил Flash)

Тесты: lksecurity_test, lkagent_test, lkdist_test, modules/lk (CSRF, заголовки, режим ожидания),
живые lkcoverage/lkunpriced, scripts/lk-sec-smoke.sh — проверка работающего кабинета снаружи.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 23:47:10 +03:00

384 lines
16 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"
"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, "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()
// Форма входа подписана токеном 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("признак администратора перешёл из чужого запроса")
}
}
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"))
}
}
}
// 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))
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)
}
}