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) }