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>
239 lines
7.1 KiB
Go
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)
|
|
}
|
|
}
|