Files
Bridge-and-Join-s/internal/lkgateway/lkphp_test.go
T
zuevav 054222d154 Кабинет: страницы собирает ядро, на хостинге — ретранслятор (Go и PHP)
- modules/lk: конверт запроса/ответа (PageRequest/PageResponse), ServeEnvelope — тот же
  обработчик страниц внутри ядра; клиент к API внутри процесса (Config.Local/LocalKey);
  режим ретранслятора (Config.Relay, BJ_RENDER=core, выбор в установщике и lk.json)
- ядро: встроенный кабинет (lkcabinet.go), POST /api/lk/render для прямого режима,
  цикл режима ожидания выполняет конверты страниц; API через ретранслятор недостижимо
- PHP-ретранслятор для обычного хостинга (modules/lk/relay-php: index.php, .htaccess,
  README): установщик в браузере, прямой режим через curl, режим ожидания через очередь
  в SQLite (BEGIN IMMEDIATE, busy_timeout), конфиг в bj-lk-data рядом с сайтом или в
  data под .htaccess; архив со страницы настроек (/admin/setup/lk/relay-php.zip)
- настройки: карточка НПР/УДС перенесена из раздела «Лицензия» в «Экспорт QUIK»;
  кнопка «Обновить с сервера лицензий» видна при любом ключе
- инструкция /admin/help/lk: два варианта размещения, шаги для PHP-хостинга
- тесты: TestLKRelayRendersInCore (оба режима), TestLKPHPRelayAgentMode (настоящий PHP
  против цикла ядра, PHP_BIN), проверка безопасности через PHP-ретранслятор 34/34

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL
2026-09-18 11:15:29 +03:00

112 lines
3.9 KiB
Go

package lkgateway
import (
"context"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
// PHP-ретранслятор в режиме ожидания против настоящего цикла ядра. Нужен интерпретатор
// PHP: путь в переменной PHP_BIN, иначе тест пропускается.
func TestLKPHPRelayAgentMode(t *testing.T) {
phpBin := os.Getenv("PHP_BIN")
if phpBin == "" {
t.Skip("PHP_BIN не задан")
}
const key = "lk-php-agent-key"
site := filepath.Join(t.TempDir(), "site")
if err := os.MkdirAll(site, 0o755); err != nil {
t.Fatal(err)
}
src, err := os.ReadFile("../../modules/lk/relay-php/index.php")
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(site, "index.php"), src, 0o644); err != nil {
t.Fatal(err)
}
// настройки сразу в файле — установщик проверен отдельно
data := filepath.Join(filepath.Dir(site), "bj-lk-data")
_ = os.MkdirAll(data, 0o700)
if err := os.WriteFile(filepath.Join(data, "config.php"), []byte("<?php exit; ?>\n{\"mode\":\"agent\",\"bj_key\":\""+key+"\",\"title\":\"PHP\"}"), 0o600); err != nil {
t.Fatal(err)
}
const addr = "127.0.0.1:18097"
cmd := exec.Command(phpBin, "-S", addr, "index.php")
cmd.Dir = site
// Встроенный сервер PHP однопоточный, а очередь требует двух одновременных запросов
// (ждущий браузер и подключение ядра). На хостинге это обеспечивает php-fpm/Apache.
cmd.Env = append(os.Environ(), "PHP_CLI_SERVER_WORKERS=4")
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
defer func() { _ = cmd.Process.Kill(); _, _ = cmd.Process.Wait() }()
time.Sleep(700 * time.Millisecond)
rc, err := NewRuntimeConfig(t.TempDir() + "/setup.json")
if err != nil {
t.Fatal(err)
}
lkCfg := rc.Snapshot().LK
lkCfg.APIKey, lkCfg.AgentMode, lkCfg.CabinetURL = key, true, "http://"+addr
if err := rc.UpdateLK(lkCfg); err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
registerLKAPI(mux, rc, nil)
core := &Server{rc: rc, mux: mux}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go core.lkAgentLoop(ctx)
client := &http.Client{Timeout: 40 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
resp, err := client.Get("http://" + addr + "/login")
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), "Логин или номер договора") {
t.Fatalf("страница через PHP-очередь: код %d, тело %.300s", resp.StatusCode, body)
}
if resp.Header.Get("X-Frame-Options") != "DENY" || resp.Header.Get("Content-Security-Policy") == "" {
t.Error("защитные заголовки ядра не дошли через PHP")
}
var csrf bool
for _, c := range resp.Cookies() {
if c.Name == "bj_lk_csrf" && c.HttpOnly {
csrf = true
}
}
if !csrf {
t.Error("кука формы не дошла через PHP")
}
// без ключа очередь закрыта
resp2, err := client.Get("http://" + addr + "/agent/tasks")
if err != nil {
t.Fatal(err)
}
resp2.Body.Close()
if resp2.StatusCode != http.StatusForbidden {
t.Errorf("/agent/tasks без ключа: код %d", resp2.StatusCode)
}
// состояние связи для администратора — по ключу
req, _ := http.NewRequest(http.MethodGet, "http://"+addr+"/health", nil)
req.Header.Set("X-BJ-Key", key)
resp3, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
h, _ := io.ReadAll(resp3.Body)
resp3.Body.Close()
if resp3.StatusCode != http.StatusOK || !strings.Contains(string(h), `"mode":"agent"`) {
t.Errorf("/health по ключу: %d %s", resp3.StatusCode, h)
}
}