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
583 lines
16 KiB
Go
583 lines
16 KiB
Go
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)
|
|
}
|