Files
mrleo1nidandClaude Opus 5.5 488abc2d34
CI / test (push) Successful in 1m29s
Агенты подхватывают изменения с сервера: каталог и самообновление
- сервер отдаёт агенту итоговый каталог (встроенный каталог сервера +
  переопределения клиента); если каталог не подходит версии агента —
  встроенный каталог агента и предупреждение
- самообновление перед бэкапом: версия сервера новее → скачивание с того же
  сервера (только /dl/...), проверка sha256 и запуска новой версии, подмена
  с откатом, перезапуск через exec; при ошибке бэкап идёт текущей версией
- галочка «Автообновление» у клиента (по умолчанию включена), версия агента
  в списке и карточке, отставшие подсвечены; версия сервера в шапке
- миграция существующей базы: колонки auto_update и agent_version
- backupctl backup --no-update

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

239 lines
7.1 KiB
Go

package server
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest"
)
func newTestServer(t *testing.T) (*Server, *httptest.Server) {
t.Helper()
s, err := New(Config{DataDir: t.TempDir(), PublicURL: "https://backup.test"})
if err != nil {
t.Fatal(err)
}
ts := httptest.NewServer(s.Handler())
t.Cleanup(func() { ts.Close(); s.Close() })
return s, ts
}
func newTestHTTP(t *testing.T, s *Server) string {
t.Helper()
ts := httptest.NewServer(s.Handler())
t.Cleanup(ts.Close)
return ts.URL
}
func call(t *testing.T, method, url, token string, body io.Reader) *http.Response {
t.Helper()
req, err := http.NewRequest(method, url, body)
if err != nil {
t.Fatal(err)
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { resp.Body.Close() })
return resp
}
func decode(t *testing.T, resp *http.Response, v any) {
t.Helper()
if err := json.NewDecoder(resp.Body).Decode(v); err != nil {
t.Fatal(err)
}
}
func sum(b []byte) string {
h := sha256.Sum256(b)
return hex.EncodeToString(h[:])
}
func TestUploadAndRestoreFlow(t *testing.T) {
s, ts := newTestServer(t)
agentTok := newToken()
clientID, err := s.st.CreateClient("nl1", "nl1.example.com", hashToken(agentTok))
if err != nil {
t.Fatal(err)
}
// без токена — 401
if r := call(t, "GET", ts.URL+"/api/v1/agent/config", "", nil); r.StatusCode != 401 {
t.Fatalf("без токена: %d", r.StatusCode)
}
var cfg AgentConfigResponse
r := call(t, "GET", ts.URL+"/api/v1/agent/config", agentTok, nil)
decode(t, r, &cfg)
if cfg.Domain != "nl1.example.com" || !cfg.HeavyDue {
t.Fatalf("config: %+v", cfg)
}
m := manifest.Manifest{FormatVersion: 1, Hostname: "nl1", Parts: []string{"main", "stoat"}}
raw, _ := json.Marshal(m)
r = call(t, "POST", ts.URL+"/api/v1/backups", agentTok, bytes.NewReader(raw))
if r.StatusCode != 201 {
t.Fatalf("create: %d", r.StatusCode)
}
var created struct{ ID int64 }
decode(t, r, &created)
mainData := []byte("main-archive-data")
heavyData := bytes.Repeat([]byte("minio"), 1000)
for name, data := range map[string][]byte{"main": mainData, "stoat": heavyData} {
r = call(t, "PUT", fmt.Sprintf("%s/api/v1/backups/%d/parts/%s", ts.URL, created.ID, name), agentTok, bytes.NewReader(data))
if r.StatusCode != 200 {
t.Fatalf("upload %s: %d", name, r.StatusCode)
}
}
// часть вне манифеста
r = call(t, "PUT", fmt.Sprintf("%s/api/v1/backups/%d/parts/other", ts.URL, created.ID), agentTok, strings.NewReader("x"))
if r.StatusCode != 400 {
t.Fatalf("чужая часть: %d", r.StatusCode)
}
done := CompleteRequest{Parts: []PartInfo{
{Name: "main", Size: int64(len(mainData)), SHA256: sum(mainData)},
{Name: "stoat", Size: int64(len(heavyData)), SHA256: sum(heavyData)},
}}
raw, _ = json.Marshal(done)
r = call(t, "POST", fmt.Sprintf("%s/api/v1/backups/%d/complete", ts.URL, created.ID), agentTok, bytes.NewReader(raw))
if r.StatusCode != 200 {
body, _ := io.ReadAll(r.Body)
t.Fatalf("complete: %d %s", r.StatusCode, body)
}
c, _ := s.st.GetClient(clientID)
if c.LastBackupAt == 0 || c.LastHeavyAt == 0 {
t.Fatalf("клиент не отмечен: %+v", c)
}
r = call(t, "GET", ts.URL+"/api/v1/agent/config", agentTok, nil)
decode(t, r, &cfg)
if cfg.HeavyDue {
t.Fatal("heavy только что загружен")
}
// upload-токен не даёт читать
if r := call(t, "GET", ts.URL+"/api/v1/restore/manifest", agentTok, nil); r.StatusCode != 401 {
t.Fatalf("агентский токен читает бэкапы: %d", r.StatusCode)
}
restoreTok := newToken()
if err := s.st.CreateRestoreToken(hashToken(restoreTok), clientID, time.Hour); err != nil {
t.Fatal(err)
}
var rm RestoreManifestResponse
r = call(t, "GET", ts.URL+"/api/v1/restore/manifest", restoreTok, nil)
decode(t, r, &rm)
if rm.BackupID != created.ID || len(rm.Parts) != 2 {
t.Fatalf("restore manifest: %+v", rm)
}
var got manifest.Manifest
if err := json.Unmarshal(rm.Manifest, &got); err != nil || got.Domain != "nl1.example.com" {
t.Fatalf("manifest domain: %q %v", got.Domain, err)
}
// скачивание с Range (докачка)
req, _ := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/restore/%d/parts/main", ts.URL, created.ID), nil)
req.Header.Set("Authorization", "Bearer "+restoreTok)
req.Header.Set("Range", "bytes=5-")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != 206 || string(body) != string(mainData[5:]) {
t.Fatalf("range: %d %q", resp.StatusCode, body)
}
}
func TestChecksumMismatchFails(t *testing.T) {
s, ts := newTestServer(t)
tok := newToken()
if _, err := s.st.CreateClient("x", "x.example.com", hashToken(tok)); err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(manifest.Manifest{Parts: []string{"main"}})
r := call(t, "POST", ts.URL+"/api/v1/backups", tok, bytes.NewReader(raw))
var created struct{ ID int64 }
decode(t, r, &created)
call(t, "PUT", fmt.Sprintf("%s/api/v1/backups/%d/parts/main", ts.URL, created.ID), tok, strings.NewReader("data"))
raw, _ = json.Marshal(CompleteRequest{Parts: []PartInfo{{Name: "main", Size: 4, SHA256: "bad"}}})
r = call(t, "POST", fmt.Sprintf("%s/api/v1/backups/%d/complete", ts.URL, created.ID), tok, bytes.NewReader(raw))
if r.StatusCode != 400 {
t.Fatalf("complete с неверной суммой: %d", r.StatusCode)
}
b, _ := s.st.GetBackup(created.ID)
if b.Status != StatusFailed {
t.Fatalf("статус %s", b.Status)
}
}
func TestWebLoginAndPages(t *testing.T) {
s, ts := newTestServer(t)
if err := s.st.SetAdmin("admin", "secret-pass"); err != nil {
t.Fatal(err)
}
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
resp, err := client.Get(ts.URL + "/")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != 303 {
t.Fatalf("без сессии: %d", resp.StatusCode)
}
resp, err = client.PostForm(ts.URL+"/login", map[string][]string{"user": {"admin"}, "password": {"secret-pass"}})
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
var session *http.Cookie
for _, c := range resp.Cookies() {
if c.Name == sessionCookie {
session = c
}
}
if session == nil {
t.Fatal("нет cookie сессии")
}
for _, p := range []string{"/", "/clients/new", "/settings"} {
req, _ := http.NewRequest("GET", ts.URL+p, nil)
req.AddCookie(session)
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("%s: %d", p, resp.StatusCode)
}
}
// POST без CSRF отклоняется
req, _ := http.NewRequest("POST", ts.URL+"/clients", strings.NewReader("domain=a.example.com"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(session)
resp, err = client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != 403 {
t.Fatalf("без CSRF: %d", resp.StatusCode)
}
}