feat: add backup/restore of app configurations
All checks were successful
Check / check (pull_request) Successful in 3m25s
All checks were successful
Check / check (pull_request) Successful in 3m25s
Add export and import functionality for app configurations:
- Export single app or all apps as versioned JSON backup bundle
- Import from backup file with name-conflict detection (skip duplicates)
- Fresh SSH keys and webhook secrets generated on import
- Preserves env vars, labels, volumes, and port mappings
- Web UI: export button on app detail, backup/restore page on dashboard
- REST API: GET /api/v1/apps/{id}/export, GET /api/v1/backup/export,
POST /api/v1/backup/import
- Comprehensive test coverage for service and handler layers
This commit is contained in:
@@ -27,6 +27,11 @@ func apiRouter(tc *testContext) http.Handler {
|
||||
apiR.Get("/apps", tc.handlers.HandleAPIListApps())
|
||||
apiR.Get("/apps/{id}", tc.handlers.HandleAPIGetApp())
|
||||
apiR.Get("/apps/{id}/deployments", tc.handlers.HandleAPIListDeployments())
|
||||
|
||||
// Backup/Restore API
|
||||
apiR.Get("/apps/{id}/export", tc.handlers.HandleAPIExportApp())
|
||||
apiR.Get("/backup/export", tc.handlers.HandleAPIExportAllApps())
|
||||
apiR.Post("/backup/import", tc.handlers.HandleAPIImportApps())
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
282
internal/handlers/backup.go
Normal file
282
internal/handlers/backup.go
Normal file
@@ -0,0 +1,282 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/models"
|
||||
"sneak.berlin/go/upaas/internal/service/app"
|
||||
"sneak.berlin/go/upaas/templates"
|
||||
)
|
||||
|
||||
// importMaxBodyBytes is the maximum allowed request body size for backup import (10 MB).
|
||||
const importMaxBodyBytes = 10 << 20
|
||||
|
||||
// HandleExportApp exports a single app's configuration as a JSON download.
|
||||
func (h *Handlers) HandleExportApp() http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
appID := chi.URLParam(request, "id")
|
||||
|
||||
application, findErr := models.FindApp(request.Context(), h.db, appID)
|
||||
if findErr != nil || application == nil {
|
||||
http.NotFound(writer, request)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
bundle, exportErr := h.appService.ExportApp(request.Context(), application)
|
||||
if exportErr != nil {
|
||||
h.log.Error("failed to export app", "error", exportErr, "app", application.Name)
|
||||
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("upaas-backup-%s-%s.json",
|
||||
application.Name,
|
||||
time.Now().UTC().Format("20060102-150405"),
|
||||
)
|
||||
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
writer.Header().Set("Content-Disposition",
|
||||
`attachment; filename="`+filename+`"`)
|
||||
|
||||
encoder := json.NewEncoder(writer)
|
||||
encoder.SetIndent("", " ")
|
||||
|
||||
err := encoder.Encode(bundle)
|
||||
if err != nil {
|
||||
h.log.Error("failed to encode backup", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HandleExportAllApps exports all app configurations as a JSON download.
|
||||
func (h *Handlers) HandleExportAllApps() http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
bundle, exportErr := h.appService.ExportAllApps(request.Context())
|
||||
if exportErr != nil {
|
||||
h.log.Error("failed to export all apps", "error", exportErr)
|
||||
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("upaas-backup-all-%s.json",
|
||||
time.Now().UTC().Format("20060102-150405"),
|
||||
)
|
||||
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
writer.Header().Set("Content-Disposition",
|
||||
`attachment; filename="`+filename+`"`)
|
||||
|
||||
encoder := json.NewEncoder(writer)
|
||||
encoder.SetIndent("", " ")
|
||||
|
||||
err := encoder.Encode(bundle)
|
||||
if err != nil {
|
||||
h.log.Error("failed to encode backup", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HandleImportPage renders the import/restore page.
|
||||
func (h *Handlers) HandleImportPage() http.HandlerFunc {
|
||||
tmpl := templates.GetParsed()
|
||||
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
data := h.addGlobals(map[string]any{
|
||||
"Success": request.URL.Query().Get("success"),
|
||||
}, request)
|
||||
|
||||
h.renderTemplate(writer, tmpl, "backup_import.html", data)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleImportApps processes an uploaded backup JSON file and imports apps.
|
||||
func (h *Handlers) HandleImportApps() http.HandlerFunc {
|
||||
tmpl := templates.GetParsed()
|
||||
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
bundle, parseErr := h.parseBackupUpload(request)
|
||||
if parseErr != "" {
|
||||
data := h.addGlobals(map[string]any{"Error": parseErr}, request)
|
||||
h.renderTemplate(writer, tmpl, "backup_import.html", data)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
imported, skipped, importErr := h.appService.ImportApps(
|
||||
request.Context(), bundle,
|
||||
)
|
||||
if importErr != nil {
|
||||
h.log.Error("failed to import apps", "error", importErr)
|
||||
|
||||
data := h.addGlobals(map[string]any{
|
||||
"Error": "Import failed: " + importErr.Error(),
|
||||
}, request)
|
||||
h.renderTemplate(writer, tmpl, "backup_import.html", data)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
successMsg := fmt.Sprintf("Imported %d app(s)", len(imported))
|
||||
if len(skipped) > 0 {
|
||||
successMsg += fmt.Sprintf(", skipped %d (name conflict)", len(skipped))
|
||||
}
|
||||
|
||||
http.Redirect(writer, request,
|
||||
"/backup/import?success="+successMsg,
|
||||
http.StatusSeeOther,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// parseBackupUpload extracts and validates a BackupBundle from a multipart upload.
|
||||
// Returns the bundle and an empty string on success, or nil and an error message.
|
||||
func (h *Handlers) parseBackupUpload(
|
||||
request *http.Request,
|
||||
) (*app.BackupBundle, string) {
|
||||
request.Body = http.MaxBytesReader(nil, request.Body, importMaxBodyBytes)
|
||||
|
||||
parseErr := request.ParseMultipartForm(importMaxBodyBytes)
|
||||
if parseErr != nil {
|
||||
return nil, "Failed to parse upload: " + parseErr.Error()
|
||||
}
|
||||
|
||||
file, _, openErr := request.FormFile("backup_file")
|
||||
if openErr != nil {
|
||||
return nil, "Please select a backup file to import"
|
||||
}
|
||||
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
var bundle app.BackupBundle
|
||||
|
||||
decodeErr := json.NewDecoder(file).Decode(&bundle)
|
||||
if decodeErr != nil {
|
||||
return nil, "Invalid backup file: " + decodeErr.Error()
|
||||
}
|
||||
|
||||
if bundle.Version != 1 {
|
||||
return nil, fmt.Sprintf(
|
||||
"Unsupported backup version: %d (expected 1)", bundle.Version,
|
||||
)
|
||||
}
|
||||
|
||||
if len(bundle.Apps) == 0 {
|
||||
return nil, "Backup file contains no apps"
|
||||
}
|
||||
|
||||
return &bundle, ""
|
||||
}
|
||||
|
||||
// HandleAPIExportApp exports a single app's configuration as JSON via API.
|
||||
func (h *Handlers) HandleAPIExportApp() http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
appID := chi.URLParam(request, "id")
|
||||
|
||||
application, err := h.appService.GetApp(request.Context(), appID)
|
||||
if err != nil {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "internal server error"},
|
||||
http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if application == nil {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "app not found"},
|
||||
http.StatusNotFound)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
bundle, exportErr := h.appService.ExportApp(request.Context(), application)
|
||||
if exportErr != nil {
|
||||
h.log.Error("failed to export app", "error", exportErr)
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "failed to export app"},
|
||||
http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
h.respondJSON(writer, request, bundle, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAPIExportAllApps exports all app configurations as JSON via API.
|
||||
func (h *Handlers) HandleAPIExportAllApps() http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
bundle, exportErr := h.appService.ExportAllApps(request.Context())
|
||||
if exportErr != nil {
|
||||
h.log.Error("failed to export all apps", "error", exportErr)
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "failed to export apps"},
|
||||
http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
h.respondJSON(writer, request, bundle, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAPIImportApps imports app configurations from a JSON request body via API.
|
||||
func (h *Handlers) HandleAPIImportApps() http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
request.Body = http.MaxBytesReader(writer, request.Body, importMaxBodyBytes)
|
||||
|
||||
var bundle app.BackupBundle
|
||||
|
||||
decodeErr := json.NewDecoder(request.Body).Decode(&bundle)
|
||||
if decodeErr != nil {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "invalid request body"},
|
||||
http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if bundle.Version != 1 {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": fmt.Sprintf(
|
||||
"unsupported backup version: %d", bundle.Version,
|
||||
)},
|
||||
http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if len(bundle.Apps) == 0 {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "backup contains no apps"},
|
||||
http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
imported, skipped, importErr := h.appService.ImportApps(
|
||||
request.Context(), &bundle,
|
||||
)
|
||||
if importErr != nil {
|
||||
h.log.Error("api: failed to import apps", "error", importErr)
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "import failed: " + importErr.Error()},
|
||||
http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
h.respondJSON(writer, request, map[string]any{
|
||||
"imported": imported,
|
||||
"skipped": skipped,
|
||||
}, http.StatusOK)
|
||||
}
|
||||
}
|
||||
582
internal/handlers/backup_test.go
Normal file
582
internal/handlers/backup_test.go
Normal file
@@ -0,0 +1,582 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/models"
|
||||
"sneak.berlin/go/upaas/internal/service/app"
|
||||
)
|
||||
|
||||
// createTestAppWithConfig creates an app with env vars, labels, volumes, and ports.
|
||||
func createTestAppWithConfig(
|
||||
t *testing.T,
|
||||
tc *testContext,
|
||||
name string,
|
||||
) *models.App {
|
||||
t.Helper()
|
||||
|
||||
createdApp := createTestApp(t, tc, name)
|
||||
|
||||
// Add env vars
|
||||
ev := models.NewEnvVar(tc.database)
|
||||
ev.AppID = createdApp.ID
|
||||
ev.Key = "DATABASE_URL"
|
||||
ev.Value = "postgres://localhost/mydb"
|
||||
require.NoError(t, ev.Save(context.Background()))
|
||||
|
||||
// Add label
|
||||
label := models.NewLabel(tc.database)
|
||||
label.AppID = createdApp.ID
|
||||
label.Key = "traefik.enable"
|
||||
label.Value = "true"
|
||||
require.NoError(t, label.Save(context.Background()))
|
||||
|
||||
// Add volume
|
||||
volume := models.NewVolume(tc.database)
|
||||
volume.AppID = createdApp.ID
|
||||
volume.HostPath = "/data/app"
|
||||
volume.ContainerPath = "/app/data"
|
||||
volume.ReadOnly = false
|
||||
require.NoError(t, volume.Save(context.Background()))
|
||||
|
||||
// Add port
|
||||
port := models.NewPort(tc.database)
|
||||
port.AppID = createdApp.ID
|
||||
port.HostPort = 8080
|
||||
port.ContainerPort = 80
|
||||
port.Protocol = models.PortProtocolTCP
|
||||
require.NoError(t, port.Save(context.Background()))
|
||||
|
||||
return createdApp
|
||||
}
|
||||
|
||||
// createTestAppWithConfigPort creates an app with a custom host port.
|
||||
func createTestAppWithConfigPort(
|
||||
t *testing.T,
|
||||
tc *testContext,
|
||||
name string,
|
||||
hostPort int,
|
||||
) *models.App {
|
||||
t.Helper()
|
||||
|
||||
createdApp := createTestApp(t, tc, name)
|
||||
|
||||
ev := models.NewEnvVar(tc.database)
|
||||
ev.AppID = createdApp.ID
|
||||
ev.Key = "DATABASE_URL"
|
||||
ev.Value = "postgres://localhost/mydb"
|
||||
require.NoError(t, ev.Save(context.Background()))
|
||||
|
||||
port := models.NewPort(tc.database)
|
||||
port.AppID = createdApp.ID
|
||||
port.HostPort = hostPort
|
||||
port.ContainerPort = 80
|
||||
port.Protocol = models.PortProtocolTCP
|
||||
require.NoError(t, port.Save(context.Background()))
|
||||
|
||||
return createdApp
|
||||
}
|
||||
|
||||
func TestHandleExportApp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
createdApp := createTestAppWithConfig(t, testCtx, "export-test-app")
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/apps/"+createdApp.ID+"/export", nil)
|
||||
request = addChiURLParams(request, map[string]string{"id": createdApp.ID})
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := testCtx.handlers.HandleExportApp()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
assert.Contains(t, recorder.Header().Get("Content-Type"), "application/json")
|
||||
assert.Contains(t, recorder.Header().Get("Content-Disposition"), "attachment")
|
||||
assert.Contains(t, recorder.Header().Get("Content-Disposition"), "export-test-app")
|
||||
|
||||
var bundle app.BackupBundle
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &bundle))
|
||||
|
||||
assert.Equal(t, 1, bundle.Version)
|
||||
assert.NotEmpty(t, bundle.ExportedAt)
|
||||
require.Len(t, bundle.Apps, 1)
|
||||
|
||||
appBackup := bundle.Apps[0]
|
||||
assert.Equal(t, "export-test-app", appBackup.Name)
|
||||
assert.Equal(t, "main", appBackup.Branch)
|
||||
assert.Len(t, appBackup.EnvVars, 1)
|
||||
assert.Equal(t, "DATABASE_URL", appBackup.EnvVars[0].Key)
|
||||
assert.Equal(t, "postgres://localhost/mydb", appBackup.EnvVars[0].Value)
|
||||
assert.Len(t, appBackup.Labels, 1)
|
||||
assert.Equal(t, "traefik.enable", appBackup.Labels[0].Key)
|
||||
assert.Len(t, appBackup.Volumes, 1)
|
||||
assert.Equal(t, "/data/app", appBackup.Volumes[0].HostPath)
|
||||
assert.Len(t, appBackup.Ports, 1)
|
||||
assert.Equal(t, 8080, appBackup.Ports[0].HostPort)
|
||||
}
|
||||
|
||||
func TestHandleExportAppNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/apps/nonexistent/export", nil)
|
||||
request = addChiURLParams(request, map[string]string{"id": "nonexistent"})
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := testCtx.handlers.HandleExportApp()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, recorder.Code)
|
||||
}
|
||||
|
||||
func TestHandleExportAllApps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
createTestAppWithConfig(t, testCtx, "export-all-app1")
|
||||
createTestAppWithConfigPort(t, testCtx, "export-all-app2", 8081)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/backup/export", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := testCtx.handlers.HandleExportAllApps()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
assert.Contains(t, recorder.Header().Get("Content-Disposition"), "upaas-backup-all")
|
||||
|
||||
var bundle app.BackupBundle
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &bundle))
|
||||
|
||||
assert.Equal(t, 1, bundle.Version)
|
||||
assert.Len(t, bundle.Apps, 2)
|
||||
}
|
||||
|
||||
func TestHandleExportAllAppsEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/backup/export", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := testCtx.handlers.HandleExportAllApps()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
|
||||
var bundle app.BackupBundle
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &bundle))
|
||||
|
||||
assert.Empty(t, bundle.Apps)
|
||||
}
|
||||
|
||||
// createMultipartBackupRequest builds a multipart form request with backup JSON as a file upload.
|
||||
func createMultipartBackupRequest(
|
||||
t *testing.T,
|
||||
backupJSON string,
|
||||
) *http.Request {
|
||||
t.Helper()
|
||||
|
||||
var body bytes.Buffer
|
||||
|
||||
writer := multipart.NewWriter(&body)
|
||||
|
||||
part, err := writer.CreateFormFile("backup_file", "backup.json")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = io.WriteString(part, backupJSON)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, writer.Close())
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/backup/import", &body)
|
||||
request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
func TestHandleImportApps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
backupJSON := `{
|
||||
"version": 1,
|
||||
"exportedAt": "2025-01-01T00:00:00Z",
|
||||
"apps": [{
|
||||
"name": "imported-app",
|
||||
"repoUrl": "git@example.com:user/repo.git",
|
||||
"branch": "main",
|
||||
"dockerfilePath": "Dockerfile",
|
||||
"envVars": [{"key": "FOO", "value": "bar"}],
|
||||
"labels": [{"key": "app.name", "value": "test"}],
|
||||
"volumes": [{"hostPath": "/data", "containerPath": "/app/data", "readOnly": true}],
|
||||
"ports": [{"hostPort": 3000, "containerPort": 8080, "protocol": "tcp"}]
|
||||
}]
|
||||
}`
|
||||
|
||||
request := createMultipartBackupRequest(t, backupJSON)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := testCtx.handlers.HandleImportApps()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
// Should redirect on success
|
||||
assert.Equal(t, http.StatusSeeOther, recorder.Code)
|
||||
assert.Contains(t, recorder.Header().Get("Location"), "success=")
|
||||
|
||||
// Verify the app was created
|
||||
apps, err := models.AllApps(context.Background(), testCtx.database)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, apps, 1)
|
||||
assert.Equal(t, "imported-app", apps[0].Name)
|
||||
|
||||
// Verify env vars
|
||||
envVars, _ := apps[0].GetEnvVars(context.Background())
|
||||
require.Len(t, envVars, 1)
|
||||
assert.Equal(t, "FOO", envVars[0].Key)
|
||||
assert.Equal(t, "bar", envVars[0].Value)
|
||||
|
||||
// Verify labels
|
||||
labels, _ := apps[0].GetLabels(context.Background())
|
||||
require.Len(t, labels, 1)
|
||||
assert.Equal(t, "app.name", labels[0].Key)
|
||||
|
||||
// Verify volumes
|
||||
volumes, _ := apps[0].GetVolumes(context.Background())
|
||||
require.Len(t, volumes, 1)
|
||||
assert.Equal(t, "/data", volumes[0].HostPath)
|
||||
assert.True(t, volumes[0].ReadOnly)
|
||||
|
||||
// Verify ports
|
||||
ports, _ := apps[0].GetPorts(context.Background())
|
||||
require.Len(t, ports, 1)
|
||||
assert.Equal(t, 3000, ports[0].HostPort)
|
||||
assert.Equal(t, 8080, ports[0].ContainerPort)
|
||||
}
|
||||
|
||||
func TestHandleImportAppsSkipsDuplicateNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
// Create an existing app with same name
|
||||
createTestApp(t, testCtx, "existing-app")
|
||||
|
||||
backupJSON := `{
|
||||
"version": 1,
|
||||
"exportedAt": "2025-01-01T00:00:00Z",
|
||||
"apps": [
|
||||
{
|
||||
"name": "existing-app",
|
||||
"repoUrl": "git@example.com:user/repo.git",
|
||||
"branch": "main",
|
||||
"dockerfilePath": "Dockerfile",
|
||||
"envVars": [],
|
||||
"labels": [],
|
||||
"volumes": [],
|
||||
"ports": []
|
||||
},
|
||||
{
|
||||
"name": "new-app",
|
||||
"repoUrl": "git@example.com:user/new.git",
|
||||
"branch": "main",
|
||||
"dockerfilePath": "Dockerfile",
|
||||
"envVars": [],
|
||||
"labels": [],
|
||||
"volumes": [],
|
||||
"ports": []
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
request := createMultipartBackupRequest(t, backupJSON)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := testCtx.handlers.HandleImportApps()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusSeeOther, recorder.Code)
|
||||
assert.Contains(t, recorder.Header().Get("Location"), "skipped")
|
||||
|
||||
// Should have 2 apps total (existing + new)
|
||||
apps, err := models.AllApps(context.Background(), testCtx.database)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, apps, 2)
|
||||
}
|
||||
|
||||
func TestHandleImportAppsInvalidJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
request := createMultipartBackupRequest(t, "not valid json")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := testCtx.handlers.HandleImportApps()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
// Should render the page with error, not redirect
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
assert.Contains(t, recorder.Body.String(), "Invalid backup file")
|
||||
}
|
||||
|
||||
func TestHandleImportAppsUnsupportedVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
backupJSON := `{"version": 99, "exportedAt": "2025-01-01T00:00:00Z", "apps": [{"name": "test"}]}`
|
||||
|
||||
request := createMultipartBackupRequest(t, backupJSON)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := testCtx.handlers.HandleImportApps()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
assert.Contains(t, recorder.Body.String(), "Unsupported backup version")
|
||||
}
|
||||
|
||||
func TestHandleImportAppsEmptyBundle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
backupJSON := `{"version": 1, "exportedAt": "2025-01-01T00:00:00Z", "apps": []}`
|
||||
|
||||
request := createMultipartBackupRequest(t, backupJSON)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := testCtx.handlers.HandleImportApps()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
assert.Contains(t, recorder.Body.String(), "contains no apps")
|
||||
}
|
||||
|
||||
func TestHandleImportPage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/backup/import", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := testCtx.handlers.HandleImportPage()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
assert.Contains(t, recorder.Body.String(), "Import Backup")
|
||||
}
|
||||
|
||||
func TestExportImportRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
createTestAppWithConfig(t, testCtx, "roundtrip-app")
|
||||
|
||||
// Export
|
||||
exportReq := httptest.NewRequest(http.MethodGet, "/backup/export", nil)
|
||||
exportRec := httptest.NewRecorder()
|
||||
|
||||
testCtx.handlers.HandleExportAllApps().ServeHTTP(exportRec, exportReq)
|
||||
|
||||
require.Equal(t, http.StatusOK, exportRec.Code)
|
||||
|
||||
exportedJSON := exportRec.Body.String()
|
||||
|
||||
// Delete the original app
|
||||
apps, _ := models.AllApps(context.Background(), testCtx.database)
|
||||
for _, a := range apps {
|
||||
require.NoError(t, a.Delete(context.Background()))
|
||||
}
|
||||
|
||||
// Import
|
||||
importReq := createMultipartBackupRequest(t, exportedJSON)
|
||||
importRec := httptest.NewRecorder()
|
||||
|
||||
testCtx.handlers.HandleImportApps().ServeHTTP(importRec, importReq)
|
||||
|
||||
assert.Equal(t, http.StatusSeeOther, importRec.Code)
|
||||
|
||||
// Verify the app was recreated with all config
|
||||
restoredApps, _ := models.AllApps(context.Background(), testCtx.database)
|
||||
require.Len(t, restoredApps, 1)
|
||||
assert.Equal(t, "roundtrip-app", restoredApps[0].Name)
|
||||
|
||||
envVars, _ := restoredApps[0].GetEnvVars(context.Background())
|
||||
assert.Len(t, envVars, 1)
|
||||
|
||||
labels, _ := restoredApps[0].GetLabels(context.Background())
|
||||
assert.Len(t, labels, 1)
|
||||
|
||||
volumes, _ := restoredApps[0].GetVolumes(context.Background())
|
||||
assert.Len(t, volumes, 1)
|
||||
|
||||
ports, _ := restoredApps[0].GetPorts(context.Background())
|
||||
assert.Len(t, ports, 1)
|
||||
}
|
||||
|
||||
// TestAPIExportApp tests the API endpoint for exporting a single app.
|
||||
func TestAPIExportApp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tc, cookies := setupAPITest(t)
|
||||
|
||||
createdApp, err := tc.appSvc.CreateApp(t.Context(), app.CreateAppInput{
|
||||
Name: "api-export-app",
|
||||
RepoURL: "git@example.com:user/repo.git",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
rr := apiGet(t, tc, cookies, "/api/v1/apps/"+createdApp.ID+"/export")
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
var bundle app.BackupBundle
|
||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &bundle))
|
||||
|
||||
assert.Equal(t, 1, bundle.Version)
|
||||
require.Len(t, bundle.Apps, 1)
|
||||
assert.Equal(t, "api-export-app", bundle.Apps[0].Name)
|
||||
}
|
||||
|
||||
// TestAPIExportAppNotFound tests the API endpoint for a nonexistent app.
|
||||
func TestAPIExportAppNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tc, cookies := setupAPITest(t)
|
||||
|
||||
rr := apiGet(t, tc, cookies, "/api/v1/apps/nonexistent/export")
|
||||
assert.Equal(t, http.StatusNotFound, rr.Code)
|
||||
}
|
||||
|
||||
// TestAPIExportAllApps tests the API endpoint for exporting all apps.
|
||||
func TestAPIExportAllApps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tc, cookies := setupAPITest(t)
|
||||
|
||||
_, err := tc.appSvc.CreateApp(t.Context(), app.CreateAppInput{
|
||||
Name: "api-export-all-1",
|
||||
RepoURL: "git@example.com:user/repo1.git",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = tc.appSvc.CreateApp(t.Context(), app.CreateAppInput{
|
||||
Name: "api-export-all-2",
|
||||
RepoURL: "git@example.com:user/repo2.git",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
rr := apiGet(t, tc, cookies, "/api/v1/backup/export")
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
var bundle app.BackupBundle
|
||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &bundle))
|
||||
|
||||
assert.Len(t, bundle.Apps, 2)
|
||||
}
|
||||
|
||||
// TestAPIImportApps tests the API import endpoint.
|
||||
func TestAPIImportApps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tc, cookies := setupAPITest(t)
|
||||
|
||||
backupJSON := `{
|
||||
"version": 1,
|
||||
"exportedAt": "2025-01-01T00:00:00Z",
|
||||
"apps": [{
|
||||
"name": "api-imported-app",
|
||||
"repoUrl": "git@example.com:user/repo.git",
|
||||
"branch": "main",
|
||||
"dockerfilePath": "Dockerfile",
|
||||
"envVars": [],
|
||||
"labels": [],
|
||||
"volumes": [],
|
||||
"ports": []
|
||||
}]
|
||||
}`
|
||||
|
||||
r := apiRouter(tc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/backup/import", strings.NewReader(backupJSON))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
var resp map[string]any
|
||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
|
||||
|
||||
imported, ok := resp["imported"].([]any)
|
||||
require.True(t, ok)
|
||||
assert.Len(t, imported, 1)
|
||||
assert.Equal(t, "api-imported-app", imported[0])
|
||||
}
|
||||
|
||||
// TestAPIImportAppsInvalidBody tests that the API rejects bad JSON.
|
||||
func TestAPIImportAppsInvalidBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tc, cookies := setupAPITest(t)
|
||||
|
||||
r := apiRouter(tc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/backup/import", strings.NewReader("not json"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, rr.Code)
|
||||
}
|
||||
|
||||
// TestAPIImportAppsUnsupportedVersion tests that the API rejects bad versions.
|
||||
func TestAPIImportAppsUnsupportedVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tc, cookies := setupAPITest(t)
|
||||
|
||||
r := apiRouter(tc)
|
||||
|
||||
body := `{"version": 42, "apps": [{"name": "x"}]}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/backup/import", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, rr.Code)
|
||||
}
|
||||
Reference in New Issue
Block a user