Files
mrleo1nidandClaude Opus 5.5 97c7051021
CI / test (push) Successful in 1m36s
Release / release (push) Successful in 1m33s
Restore: перевод бэкапов на восстановленный сервер
Раньше восстановленная копия получала конфиг и таймер агента со старым токеном
(через категории etc и units), и бэкапы шли с двух серверов одновременно.

- конфиг и юниты агента больше не бэкапятся
- в конце restore спрашивает, сделать ли сервер основным: сервер бэкапов
  выпускает новый токен (POST /api/v1/restore/takeover), restore ставит
  агента; токен старого сервера отозван — его агент получает отказ
- --takeover / --no-takeover; без них — вопрос (с --yes — да)
- уведомление «Сервер восстановлен» в ntfy/Telegram

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 15:29:34 +03:00

145 lines
5.4 KiB
Go

package server
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"os"
"path/filepath"
"testing"
"time"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/catalog"
)
func TestAgentConfigCatalogAndUpdate(t *testing.T) {
dist := t.TempDir()
bin := []byte("fake-backupctl-binary")
if err := os.WriteFile(filepath.Join(dist, "backupctl-linux-amd64"), bin, 0o755); err != nil {
t.Fatal(err)
}
s, err := New(Config{DataDir: t.TempDir(), DistDir: dist, Version: "v0.6.0"})
if err != nil {
t.Fatal(err)
}
defer s.Close()
tok := newToken()
id, err := s.st.CreateClient("x", "x.example.com", hashToken(tok))
if err != nil {
t.Fatal(err)
}
c, _ := s.st.GetClient(id)
if !c.AutoUpdate {
t.Fatal("автообновление должно быть включено по умолчанию")
}
c.ConfigYAML = "categories:\n - {name: scripts, disabled: true}\n"
if err := s.st.UpdateClientSettings(c); err != nil {
t.Fatal(err)
}
ts := newTestHTTP(t, s)
var cfg AgentConfigResponse
decode(t, call(t, "GET", ts+"/api/v1/agent/config?arch=amd64&version=v0.5.0", tok, nil), &cfg)
cat, err := catalog.Parse([]byte(cfg.CatalogYAML))
if err != nil || cat.Validate() != nil {
t.Fatalf("каталог не читается: %v", err)
}
if cat.Find("xui") == nil || !cat.Find("scripts").Disabled {
t.Fatal("в каталоге нет встроенных категорий или переопределений клиента")
}
sum := sha256.Sum256(bin)
if cfg.Update == nil || cfg.Update.Version != "v0.6.0" || cfg.Update.Path != "/dl/backupctl-linux-amd64" || cfg.Update.SHA256 != hex.EncodeToString(sum[:]) {
t.Fatalf("update: %+v", cfg.Update)
}
if c, _ := s.st.GetClient(id); c.AgentVersion != "v0.5.0" {
t.Fatalf("версия агента не записана: %q", c.AgentVersion)
}
// актуальная версия, неизвестная архитектура, выключенное автообновление — без обновления
for _, q := range []string{"arch=amd64&version=v0.6.0", "arch=riscv64&version=v0.5.0"} {
cfg = AgentConfigResponse{}
decode(t, call(t, "GET", ts+"/api/v1/agent/config?"+q, tok, nil), &cfg)
if cfg.Update != nil {
t.Fatalf("%s: обновление не ожидалось", q)
}
}
c, _ = s.st.GetClient(id)
c.AutoUpdate = false
_ = s.st.UpdateClientSettings(c)
cfg = AgentConfigResponse{}
decode(t, call(t, "GET", ts+"/api/v1/agent/config?arch=amd64&version=v0.5.0", tok, nil), &cfg)
if cfg.Update != nil {
t.Fatal("автообновление выключено, а обновление предложено")
}
}
// Базы первых релизов без новых колонок мигрируются при открытии.
func TestStoreMigration(t *testing.T) {
path := filepath.Join(t.TempDir(), "old.db")
db, err := sql.Open("sqlite", "file:"+path)
if err != nil {
t.Fatal(err)
}
_, err = db.Exec(`CREATE TABLE clients (id INTEGER PRIMARY KEY, name TEXT NOT NULL, domain TEXT NOT NULL UNIQUE,
token_hash TEXT NOT NULL UNIQUE, config_yaml TEXT NOT NULL DEFAULT '', keep_daily INTEGER NOT NULL DEFAULT 7,
keep_weekly INTEGER NOT NULL DEFAULT 4, keep_monthly INTEGER NOT NULL DEFAULT 6, heavy_keep INTEGER NOT NULL DEFAULT 1,
heavy_every_days INTEGER NOT NULL DEFAULT 7, stale_hours INTEGER NOT NULL DEFAULT 26, created_at INTEGER NOT NULL,
last_backup_at INTEGER NOT NULL DEFAULT 0, last_heavy_at INTEGER NOT NULL DEFAULT 0, last_seen_at INTEGER NOT NULL DEFAULT 0,
last_error TEXT NOT NULL DEFAULT '', last_error_at INTEGER NOT NULL DEFAULT 0, stale_notified INTEGER NOT NULL DEFAULT 0,
disabled INTEGER NOT NULL DEFAULT 0);
INSERT INTO clients (name, domain, token_hash, created_at) VALUES ('a', 'a.example.com', 'h', 1);`)
if err != nil {
t.Fatal(err)
}
db.Close()
st, err := OpenStore(path)
if err != nil {
t.Fatal(err)
}
defer st.Close()
cs, err := st.ListClients()
if err != nil || len(cs) != 1 || !cs[0].AutoUpdate {
t.Fatalf("после миграции: %v %+v", err, cs)
}
// повторное открытие не падает на уже добавленных колонках
st.Close()
if st2, err := OpenStore(path); err != nil {
t.Fatal(err)
} else {
st2.Close()
}
}
func TestTakeoverRevokesOldAgent(t *testing.T) {
s, ts := newTestServer(t)
oldTok := newToken()
id, err := s.st.CreateClient("x", "x.example.com", hashToken(oldTok))
if err != nil {
t.Fatal(err)
}
if r := call(t, "GET", ts.URL+"/api/v1/agent/config", oldTok, nil); r.StatusCode != 200 {
t.Fatalf("старый токен до takeover: %d", r.StatusCode)
}
// агентский токен takeover не делает
if r := call(t, "POST", ts.URL+"/api/v1/restore/takeover", oldTok, nil); r.StatusCode != 401 {
t.Fatalf("takeover агентским токеном: %d", r.StatusCode)
}
rt := newToken()
if err := s.st.CreateRestoreToken(hashToken(rt), id, time.Hour); err != nil {
t.Fatal(err)
}
var out struct{ Token string }
decode(t, call(t, "POST", ts.URL+"/api/v1/restore/takeover", rt, nil), &out)
if out.Token == "" || out.Token == oldTok {
t.Fatalf("новый токен: %q", out.Token)
}
if r := call(t, "GET", ts.URL+"/api/v1/agent/config", oldTok, nil); r.StatusCode != 401 {
t.Fatalf("старый сервер после takeover всё ещё принят: %d", r.StatusCode)
}
if r := call(t, "GET", ts.URL+"/api/v1/agent/config", out.Token, nil); r.StatusCode != 200 {
t.Fatalf("новый токен: %d", r.StatusCode)
}
}