backup: pre-hardening baseline
This commit is contained in:
commit
9e4c612dcb
57 changed files with 10393 additions and 0 deletions
345
backend/internal/config/file/service_test.go
Normal file
345
backend/internal/config/file/service_test.go
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
package file
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
func newTestService(t *testing.T) (*Service, string, func()) {
|
||||
t.Helper()
|
||||
dir, err := os.MkdirTemp("", "traefik-gui-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("temp dir: %v", err)
|
||||
}
|
||||
db, err := sql.Open("sqlite3", filepath.Join(dir, "test.db")+"?_foreign_keys=on")
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
// create backups table
|
||||
_, err = db.Exec(`CREATE TABLE backups (
|
||||
id TEXT PRIMARY KEY,
|
||||
filename TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by TEXT NOT NULL,
|
||||
reason TEXT NOT NULL
|
||||
)`)
|
||||
if err != nil {
|
||||
t.Fatalf("create backups: %v", err)
|
||||
}
|
||||
svc, err := NewService(filepath.Join(dir, "dynamic"), db)
|
||||
if err != nil {
|
||||
t.Fatalf("new service: %v", err)
|
||||
}
|
||||
cleanup := func() {
|
||||
db.Close()
|
||||
os.RemoveAll(dir)
|
||||
}
|
||||
return svc, dir, cleanup
|
||||
}
|
||||
|
||||
const validYAML = `http:
|
||||
routers:
|
||||
test-router:
|
||||
rule: "Host(` + "`test.example.com`" + `)"
|
||||
service: test-service
|
||||
entryPoints: ["web"]
|
||||
services:
|
||||
test-service:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://127.0.0.1:8080"
|
||||
`
|
||||
|
||||
const validYAML2 = `http:
|
||||
routers:
|
||||
test-router2:
|
||||
rule: "Host(` + "`test2.example.com`" + `)"
|
||||
service: test-service2
|
||||
entryPoints: ["web"]
|
||||
services:
|
||||
test-service2:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://127.0.0.1:8081"
|
||||
`
|
||||
|
||||
func TestValidateContent_Valid(t *testing.T) {
|
||||
errs := ValidateContent("app.yml", validYAML)
|
||||
if len(errs) != 0 {
|
||||
t.Fatalf("expected no errors, got %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateContent_InvalidYAML(t *testing.T) {
|
||||
invalid := "http:\n routers: [\ninvalid yaml"
|
||||
errs := ValidateContent("app.yml", invalid)
|
||||
if len(errs) == 0 {
|
||||
t.Fatal("expected validation errors for invalid yaml")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateContent_Empty(t *testing.T) {
|
||||
errs := ValidateContent("app.yml", " ")
|
||||
if len(errs) == 0 {
|
||||
t.Fatal("expected error for empty content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateContent_NoTopLevel(t *testing.T) {
|
||||
errs := ValidateContent("app.yml", "foo: bar\nbaz: qux\n")
|
||||
if len(errs) == 0 {
|
||||
t.Fatal("expected error for missing http/tcp/udp/tls")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateContent_DangerousFilename(t *testing.T) {
|
||||
errs := ValidateContent("../evil.yml", validYAML)
|
||||
if len(errs) == 0 {
|
||||
t.Fatal("expected error for path traversal filename")
|
||||
}
|
||||
errs = ValidateContent("app.txt", validYAML)
|
||||
if len(errs) == 0 {
|
||||
t.Fatal("expected error for wrong extension")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreview_Diff(t *testing.T) {
|
||||
svc, _, cleanup := newTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
// Preview new file (no existing)
|
||||
res := svc.Preview("app.yml", validYAML)
|
||||
if !res.Valid {
|
||||
t.Fatalf("preview should be valid, got errs %v", res.Errors)
|
||||
}
|
||||
if res.Diff == "" {
|
||||
t.Fatal("expected diff for new file")
|
||||
}
|
||||
// Preview with same content -> no diff? Actually diff will be content vs empty, so diff present
|
||||
// Second: after apply, preview same content should have empty diff
|
||||
_, err := svc.Apply("app.yml", validYAML, "test-user", true)
|
||||
if err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
res2 := svc.Preview("app.yml", validYAML)
|
||||
if !res2.Valid {
|
||||
t.Fatalf("preview2 valid %v", res2.Errors)
|
||||
}
|
||||
// Same content should give empty diff
|
||||
if res2.Diff != "" {
|
||||
t.Fatalf("expected empty diff for same content, got %q", res2.Diff)
|
||||
}
|
||||
// Different content should give diff
|
||||
res3 := svc.Preview("app.yml", validYAML2)
|
||||
if res3.Diff == "" {
|
||||
t.Fatal("expected diff for changed content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtomicWrite(t *testing.T) {
|
||||
svc, _, cleanup := newTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
// Apply valid
|
||||
res, err := svc.Apply("app.yml", validYAML, "user1", true)
|
||||
if err != nil {
|
||||
t.Fatalf("apply err %v", err)
|
||||
}
|
||||
if !res.Valid {
|
||||
t.Fatalf("not valid %v", res.Errors)
|
||||
}
|
||||
// Read back
|
||||
content, err := svc.ReadFile("app.yml")
|
||||
if err != nil {
|
||||
t.Fatalf("read %v", err)
|
||||
}
|
||||
if content != validYAML {
|
||||
t.Fatalf("content mismatch")
|
||||
}
|
||||
// Ensure no temp files left
|
||||
files, _ := svc.ListFiles()
|
||||
for _, f := range files {
|
||||
if len(f) > 4 && f[len(f)-4:] == ".tmp" {
|
||||
t.Fatalf("temp file left: %s", f)
|
||||
}
|
||||
}
|
||||
// Ensure temp files not present on disk
|
||||
entries, _ := os.ReadDir(svc.ConfigDir())
|
||||
for _, e := range entries {
|
||||
if len(e.Name()) > 4 && contains(e.Name(), ".tmp.") {
|
||||
t.Fatalf("temp file on disk: %s", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
return len(s) >= len(sub) && (func() bool {
|
||||
for i := 0; i <= len(s)-len(sub); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})()
|
||||
}
|
||||
|
||||
func TestRejectInvalid(t *testing.T) {
|
||||
svc, _, cleanup := newTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
// Try apply invalid YAML - should not write
|
||||
res, err := svc.Apply("bad.yml", "http:\n bad: [\n", "user1", true)
|
||||
if err != nil {
|
||||
t.Fatalf("apply should return preview error, not err %v", err)
|
||||
}
|
||||
if res.Valid {
|
||||
t.Fatal("invalid yaml should be rejected")
|
||||
}
|
||||
// Ensure file not created
|
||||
if _, err := svc.ReadFile("bad.yml"); err == nil {
|
||||
t.Fatal("invalid file should not be created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectEmpty(t *testing.T) {
|
||||
svc, _, cleanup := newTestService(t)
|
||||
defer cleanup()
|
||||
res, _ := svc.Apply("empty.yml", " ", "user1", true)
|
||||
if res.Valid {
|
||||
t.Fatal("empty should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectNoConfirm(t *testing.T) {
|
||||
svc, _, cleanup := newTestService(t)
|
||||
defer cleanup()
|
||||
_, err := svc.Apply("app.yml", validYAML, "user1", false)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing confirm")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollback(t *testing.T) {
|
||||
svc, _, cleanup := newTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
// Apply v1
|
||||
_, err := svc.Apply("app.yml", validYAML, "user1", true)
|
||||
if err != nil {
|
||||
t.Fatalf("apply v1 %v", err)
|
||||
}
|
||||
// Apply v2
|
||||
_, err = svc.Apply("app.yml", validYAML2, "user1", true)
|
||||
if err != nil {
|
||||
t.Fatalf("apply v2 %v", err)
|
||||
}
|
||||
// Verify v2 present
|
||||
content, _ := svc.ReadFile("app.yml")
|
||||
if content != validYAML2 {
|
||||
t.Fatalf("expected v2")
|
||||
}
|
||||
// Rollback to previous (v1)
|
||||
_, err = svc.Rollback("app.yml", "", "user1")
|
||||
if err != nil {
|
||||
t.Fatalf("rollback %v", err)
|
||||
}
|
||||
content, _ = svc.ReadFile("app.yml")
|
||||
if content != validYAML {
|
||||
t.Fatalf("expected rollback to v1, got %q", content)
|
||||
}
|
||||
// Check history has entries
|
||||
hist, err := svc.History("app.yml")
|
||||
if err != nil {
|
||||
t.Fatalf("history %v", err)
|
||||
}
|
||||
if len(hist) < 2 {
|
||||
t.Fatalf("expected at least 2 history entries, got %d", len(hist))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollbackSpecificBackup(t *testing.T) {
|
||||
svc, _, cleanup := newTestService(t)
|
||||
defer cleanup()
|
||||
svc.Apply("app.yml", validYAML, "user1", true)
|
||||
svc.Apply("app.yml", validYAML2, "user1", true)
|
||||
hist, _ := svc.History("app.yml")
|
||||
if len(hist) < 2 {
|
||||
t.Fatalf("need 2 backups")
|
||||
}
|
||||
// hist[0] is most recent (before v2), hist[1] is before v1 (empty)
|
||||
// Rollback to specific backup: choose oldest that has content validYAML
|
||||
// The most recent backup content is validYAML (before v2)
|
||||
_, err := svc.Rollback("app.yml", hist[0].ID, "user1")
|
||||
if err != nil {
|
||||
t.Fatalf("rollback specific %v", err)
|
||||
}
|
||||
content, _ := svc.ReadFile("app.yml")
|
||||
if content != validYAML {
|
||||
t.Fatalf("expected v1 after specific rollback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentUpdates(t *testing.T) {
|
||||
svc, _, cleanup := newTestService(t)
|
||||
defer cleanup()
|
||||
// Start with valid
|
||||
svc.Apply("concurrent.yml", validYAML, "user1", true)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, 10)
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
content := validYAML
|
||||
if idx%2 == 0 {
|
||||
content = validYAML2
|
||||
}
|
||||
_, err := svc.Apply("concurrent.yml", content, "user1", true)
|
||||
errs[idx] = err
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
for i, e := range errs {
|
||||
if e != nil {
|
||||
t.Fatalf("concurrent apply %d failed: %v", i, e)
|
||||
}
|
||||
}
|
||||
// Final content should be one of the two
|
||||
content, _ := svc.ReadFile("concurrent.yml")
|
||||
if content != validYAML && content != validYAML2 {
|
||||
t.Fatalf("unexpected final content")
|
||||
}
|
||||
// History should have 11 entries (initial + 10)
|
||||
hist, _ := svc.History("concurrent.yml")
|
||||
if len(hist) != 11 {
|
||||
t.Fatalf("expected 11 history, got %d", len(hist))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileOwnershipAndValidation(t *testing.T) {
|
||||
svc, _, cleanup := newTestService(t)
|
||||
defer cleanup()
|
||||
|
||||
// Try to write outside directory via traversal — should fail validation
|
||||
res, _ := svc.Apply("../evil.yml", validYAML, "user1", true)
|
||||
if res.Valid {
|
||||
t.Fatal("expected invalid for traversal")
|
||||
}
|
||||
// Try wrong extension
|
||||
res, _ = svc.Apply("evil.txt", validYAML, "user1", true)
|
||||
if res.Valid {
|
||||
t.Fatal("expected invalid for wrong extension")
|
||||
}
|
||||
// Try empty filename
|
||||
res2 := svc.Preview("", validYAML)
|
||||
if res2.Valid {
|
||||
t.Fatal("expected invalid for empty filename")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue