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>
384 lines
16 KiB
Go
384 lines
16 KiB
Go
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)
|
||
}
|
||
}
|