Кабинет: веб-установщик и управление пользователями в ядре
- установщик в самом кабинете: до настройки любая страница ведёт в /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:
+98
-5
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user