347 lines
8.6 KiB
Go
347 lines
8.6 KiB
Go
package file
|
|
|
|
import (
|
|
"database/sql"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
|
|
_ "github.com/mattn/go-sqlite3"
|
|
"github.com/traefik/traefik-gui/backend/internal/database/repositories"
|
|
)
|
|
|
|
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)
|
|
}
|
|
auditRepo := repositories.NewAuditRepository(db)
|
|
svc, err := NewService(filepath.Join(dir, "dynamic"), db, auditRepo)
|
|
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")
|
|
}
|
|
}
|