Кабинет: веб-установщик и управление пользователями в ядре

- установщик в самом кабинете: до настройки любая страница ведёт в /install; три шага —
  проверка сервера (каталог данных, https), связь с ядром (режим, адрес, ключ; связь
  проверяется до сохранения, в режиме ожидания страница ждёт подключения ядра),
  администратор кабинета (заводится в ядре через /api/lk/admin/bootstrap, только пока
  администраторов нет); настройки в data/lk.json 0600, окружение перекрывает файл;
  после установки установщик закрыт
- настройки кабинета можно менять на ходу: Server за мьютексом, один mux на всё время
- ключ модуля больше не кладётся в архив: его вводят в установщике; на странице настроек
  ключ показан целиком 10 минут после создания, потом маска
- ядро: карточка «Пользователи кабинета» в настройках — список, заведение, смена пароля
  (гасит сессии), блокировка, права сотрудника, разрешение поручений, удаление; письма
  не отправляются
- инструкция /admin/help/lk дополнена шагами установщика и разделом о подходящем хостинге
- тесты: TestInstaller, TestNotConfigured, TestSetupPageLKUsersAndLink; установщик
  проверен вживую вторым экземпляром против боевого ядра

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
zuevav
2026-09-18 09:30:47 +03:00
parent e1bf7c440f
commit 222d48e740
14 changed files with 977 additions and 105 deletions
+98 -5
View File
@@ -7,6 +7,8 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -81,6 +83,19 @@ func fakeBJ(t *testing.T) *httptest.Server {
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
}
})
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
@@ -155,20 +170,94 @@ func TestAdminFlagPerRequest(t *testing.T) {
}
}
// Кабинет без настроек ведёт в веб-установщик, а /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("без ключа кабинет не настроен")
}
for _, p := range []string{"/", "/health"} {
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()
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())
}
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("окружение должно перекрывать файл")
}
}
@@ -254,6 +343,10 @@ 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