Files
Bridge-and-Join-s/internal/lkgateway/licenseclient_test.go
T
zuevav fd238c1078 feat(admin): dev-страница «Эмуляторы» — переключатели и настройки эмуляции НРД и МНП QUIK
В режиме разработки новая страница /admin/emulators с двумя клавишами:

- «Эмулятор НРД» (флаг LK.EmulateNSD): заявки ЛК и ручного ввода идут через
  внутренний имитатор, в НРД ничего не отправляется. Настройки: задержка
  решения (0–300 с), исход (подтвердить/отклонить/не отвечать), код отказа
  M2M01–M2M09/18. Сендер перенастраивается в рантайме через потокобезопасный
  mock.Sender.Reconfigure — без перезапуска.
- «Эмулятор МНП QUIK» (флаг QuikMNP.Emulate): шаги цикла без записи в боевую
  БД МНП. Настройки: задержка шага (0–60 с), точечные отказы по шагам
  (регистрация/подпись/отказ/зачисление) — эмулированный отказ ведёт себя как
  боевой: шаг OK=false, состояние не продвигается, повтор на следующем проходе.

Настройки хранятся в setup.json (nsd_emu/mnp_emu), обратная совместимость —
дефолты. Баннер «Режим эмуляции» показывается и при ручном включении
эмуляторов (раньше зависел только от отсутствия ИШ). Пункт меню «Эмуляторы»
и две плитки на дашборде — только в dev. Пути занесены в devOnlyPath.

Попутно: исправлен race в TestLicenseActivateAndRenew (cleanup восстанавливал
DefaultLicensePublicKey без moduleEnforceMu — падал под -race и на чистом HEAD).
2026-10-07 21:34:39 +03:00

144 lines
5.1 KiB
Go

package lkgateway
import (
"context"
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/license"
)
// testLicenseKey подменяет зашитый ключ проверки тестовой парой.
func testLicenseKey(t *testing.T) ed25519.PrivateKey {
t.Helper()
pub, priv, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatal(err)
}
prev := DefaultLicensePublicKey
DefaultLicensePublicKey = base64.StdEncoding.EncodeToString(pub)
t.Cleanup(func() {
// Ждём возможный фоновый enforceModuleLicense (он читает ключ в licenseStatus),
// иначе восстановление ключа гоняется с ним под -race.
moduleEnforceMu.Lock()
defer moduleEnforceMu.Unlock()
DefaultLicensePublicKey = prev
})
return priv
}
func testRC(t *testing.T) *RuntimeConfig {
t.Helper()
rc, err := NewRuntimeConfig(filepath.Join(t.TempDir(), "setup.json"))
if err != nil {
t.Fatal(err)
}
return rc
}
func signTestLicense(t *testing.T, priv ed25519.PrivateKey, l *license.License) string {
t.Helper()
tok, err := license.Sign(l, priv, "main")
if err != nil {
t.Fatal(err)
}
return tok.Encode()
}
func TestLicenseActivateAndRenew(t *testing.T) {
priv := testLicenseKey(t)
rc := testRC(t)
var gotInstall string
renewed := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var in map[string]string
_ = json.NewDecoder(r.Body).Decode(&in)
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/v1/activate" && in["code"] != "BJ-TEST-CODE-0001" {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":"unknown_code","message":"код активации не найден"}`))
return
}
if r.URL.Path == "/v1/renew" {
renewed++
}
gotInstall = in["install_id"]
now := time.Now().UTC()
tok := signTestLicense(t, priv, &license.License{Schema: 1, ID: "lic-1", Tenant: "Тест", Product: "bj-server", Plan: license.PlanPro,
IssuedAt: now, ExpiresAt: now.Add(60 * 24 * time.Hour), Features: []string{"npr"}, InstallID: in["install_id"], ContractUntil: now.AddDate(1, 0, 0)})
_ = json.NewEncoder(w).Encode(map[string]any{"token": tok, "license_id": "lic-1", "tenant": "Тест"})
}))
defer srv.Close()
prev := licenseServer
licenseServer = srv.URL
t.Cleanup(func() { licenseServer = prev })
ctx := context.Background()
if err := LicenseActivate(ctx, rc, "BJ-XXXX-XXXX-XXXX"); err == nil || !strings.Contains(err.Error(), "не найден") {
t.Fatalf("неверный код: ожидали отказ сервера, получили %v", err)
}
if licenseStatus(rc).Present {
t.Fatal("после отказа ключ не должен сохраняться")
}
if err := LicenseActivate(ctx, rc, "BJ-TEST-CODE-0001"); err != nil {
t.Fatal(err)
}
st := licenseStatus(rc)
if !st.Valid || !st.Online || st.Tenant != "Тест" {
t.Fatalf("после активации: %+v", st)
}
if gotInstall == "" || gotInstall != rc.Snapshot().License.InstallID {
t.Fatalf("install_id в запросе %q, в настройках %q", gotInstall, rc.Snapshot().License.InstallID)
}
if !moduleAllowed(rc, "npr") || moduleAllowed(rc, "quik-mnp") {
t.Fatal("модули должны следовать признакам лицензии pro")
}
if licenseNeedsRenew(rc) {
t.Fatal("сразу после активации продлевать не нужно")
}
if err := LicenseRenew(ctx, rc); err != nil || renewed != 1 {
t.Fatalf("продление: %v, обращений %d", err, renewed)
}
s := rc.Snapshot().License
s.InstallID = "00000000-0000-4000-8000-000000000000"
_ = rc.SaveLicense(s)
if st := licenseStatus(rc); st.Valid || !strings.Contains(st.Message, "другой установке") {
t.Fatalf("ключ чужой установки должен быть недействителен: %+v", st)
}
}
func TestLicenseOfflineEnterpriseAndForeignKey(t *testing.T) {
priv := testLicenseKey(t)
rc := testRC(t)
now := time.Now().UTC()
s := rc.Snapshot().License
s.Key = signTestLicense(t, priv, &license.License{Schema: 1, ID: "lic-2", Tenant: "Офлайн", Product: "bj-server", Plan: license.PlanEnterprise,
IssuedAt: now, ExpiresAt: now.AddDate(1, 0, 0)})
_ = rc.SaveLicense(s)
st := licenseStatus(rc)
if !st.Valid || st.Online {
t.Fatalf("офлайн-ключ: %+v", st)
}
if !moduleAllowed(rc, "quik-export") || !moduleAllowed(rc, "npr") {
t.Fatal("enterprise должен открывать все модули")
}
if err := LicenseRenew(context.Background(), rc); err != errLicenseOffline {
t.Fatalf("офлайн-ключ не продлевается: %v", err)
}
_, other, _ := ed25519.GenerateKey(nil)
s.Key = signTestLicense(t, other, &license.License{Schema: 1, ID: "x", Plan: license.PlanEnterprise, IssuedAt: now, ExpiresAt: now.AddDate(1, 0, 0)})
_ = rc.SaveLicense(s)
if licenseStatus(rc).Valid || moduleAllowed(rc, "npr") {
t.Fatal("ключ, подписанный чужим ключом, не должен приниматься")
}
}