Update golangci-lint to v2.12.2 with canonical config
All checks were successful
Check / check (pull_request) Successful in 3m22s
All checks were successful
Check / check (pull_request) Successful in 3m22s
Bump golangci-lint from v2.10.1 to v2.12.2 in the Dockerfile lint stage (tag+digest pin) and script/bootstrap release-archive pins (linux amd64/arm64 sha256s). Replace .golangci.yml with the canonical v2-layout config so linter settings (lll 88, funlen 80/50, cyclop 15, dupl 100) actually apply. Fix all findings surfaced by the new linter and config: - noctx: use httptest.NewRequestWithContext in all tests - gosec G710/G703: route app redirects through a path-escaping redirectToApp helper; annotate internal log path usage - goconst: introduce shared constants for template/JSON keys and repeated test literals - lll: wrap lines to the 88-column limit - dupl: extract shared helpers (generic findAllByAppID in models, deleteAppResource in handlers, parsePush in webhook payloads, table-driven/helper-based test dedup) - nolintlint: drop nolint directives made obsolete by the new limits Record the change in TODO.md; make check is green.
This commit is contained in:
@@ -84,7 +84,7 @@ func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
|
||||
decodeErr := json.NewDecoder(request.Body).Decode(&req)
|
||||
if decodeErr != nil {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "invalid JSON body"},
|
||||
map[string]string{jsonKeyError: "invalid JSON body"},
|
||||
http.StatusBadRequest)
|
||||
|
||||
return
|
||||
@@ -95,7 +95,7 @@ func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
|
||||
|
||||
if username == "" || credential == "" {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "username and password are required"},
|
||||
map[string]string{jsonKeyError: "username and password are required"},
|
||||
http.StatusBadRequest)
|
||||
|
||||
return
|
||||
@@ -104,7 +104,7 @@ func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
|
||||
user, authErr := h.auth.Authenticate(request.Context(), username, credential)
|
||||
if authErr != nil {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "invalid credentials"},
|
||||
map[string]string{jsonKeyError: "invalid credentials"},
|
||||
http.StatusUnauthorized)
|
||||
|
||||
return
|
||||
@@ -114,7 +114,7 @@ func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
|
||||
if sessionErr != nil {
|
||||
h.log.Error("api: failed to create session", "error", sessionErr)
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "failed to create session"},
|
||||
map[string]string{jsonKeyError: "failed to create session"},
|
||||
http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
@@ -133,7 +133,7 @@ func (h *Handlers) HandleAPIListApps() http.HandlerFunc {
|
||||
apps, err := h.appService.ListApps(request.Context())
|
||||
if err != nil {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "failed to list apps"},
|
||||
map[string]string{jsonKeyError: "failed to list apps"},
|
||||
http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
@@ -156,7 +156,7 @@ func (h *Handlers) HandleAPIGetApp() http.HandlerFunc {
|
||||
application, err := h.appService.GetApp(request.Context(), appID)
|
||||
if err != nil {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "internal server error"},
|
||||
map[string]string{jsonKeyError: "internal server error"},
|
||||
http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
@@ -164,7 +164,7 @@ func (h *Handlers) HandleAPIGetApp() http.HandlerFunc {
|
||||
|
||||
if application == nil {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "app not found"},
|
||||
map[string]string{jsonKeyError: "app not found"},
|
||||
http.StatusNotFound)
|
||||
|
||||
return
|
||||
@@ -185,7 +185,7 @@ func (h *Handlers) HandleAPIListDeployments() http.HandlerFunc {
|
||||
application, err := h.appService.GetApp(request.Context(), appID)
|
||||
if err != nil || application == nil {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "app not found"},
|
||||
map[string]string{jsonKeyError: "app not found"},
|
||||
http.StatusNotFound)
|
||||
|
||||
return
|
||||
@@ -205,7 +205,7 @@ func (h *Handlers) HandleAPIListDeployments() http.HandlerFunc {
|
||||
)
|
||||
if deployErr != nil {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "failed to list deployments"},
|
||||
map[string]string{jsonKeyError: "failed to list deployments"},
|
||||
http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
@@ -231,7 +231,7 @@ func (h *Handlers) HandleAPIWhoAmI() http.HandlerFunc {
|
||||
user, err := h.auth.GetCurrentUser(request.Context(), request)
|
||||
if err != nil || user == nil {
|
||||
h.respondJSON(writer, request,
|
||||
map[string]string{"error": "unauthorized"},
|
||||
map[string]string{jsonKeyError: "unauthorized"},
|
||||
http.StatusUnauthorized)
|
||||
|
||||
return
|
||||
|
||||
@@ -47,7 +47,12 @@ func setupAPITest(t *testing.T) (*testContext, []*http.Cookie) {
|
||||
r := apiRouter(tc)
|
||||
|
||||
loginBody := `{"username":"admin","password":"password123"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(loginBody))
|
||||
req := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
http.MethodPost,
|
||||
"/api/v1/login",
|
||||
strings.NewReader(loginBody),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
@@ -70,7 +75,7 @@ func apiGet(
|
||||
) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, path, nil)
|
||||
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
@@ -95,7 +100,12 @@ func TestAPILoginSuccess(t *testing.T) {
|
||||
r := apiRouter(tc)
|
||||
|
||||
body := `{"username":"admin","password":"password123"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(body))
|
||||
req := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
http.MethodPost,
|
||||
"/api/v1/login",
|
||||
strings.NewReader(body),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
@@ -122,7 +132,12 @@ func TestAPILoginInvalidCredentials(t *testing.T) {
|
||||
r := apiRouter(tc)
|
||||
|
||||
body := `{"username":"admin","password":"wrong"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(body))
|
||||
req := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
http.MethodPost,
|
||||
"/api/v1/login",
|
||||
strings.NewReader(body),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
@@ -139,7 +154,12 @@ func TestAPILoginMissingFields(t *testing.T) {
|
||||
r := apiRouter(tc)
|
||||
|
||||
body := `{"username":"","password":""}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(body))
|
||||
req := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
http.MethodPost,
|
||||
"/api/v1/login",
|
||||
strings.NewReader(body),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
@@ -155,7 +175,9 @@ func TestAPIRejectsUnauthenticated(t *testing.T) {
|
||||
|
||||
r := apiRouter(tc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/apps", nil)
|
||||
req := httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, "/api/v1/apps", nil,
|
||||
)
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/database"
|
||||
"sneak.berlin/go/upaas/internal/models"
|
||||
"sneak.berlin/go/upaas/internal/service/app"
|
||||
"sneak.berlin/go/upaas/templates"
|
||||
@@ -27,6 +29,23 @@ const (
|
||||
deploymentsHistoryLimit = 50
|
||||
)
|
||||
|
||||
// redirectToApp issues a SeeOther redirect to the page for the given
|
||||
// app ID, with an optional suffix such as "/deployments" or
|
||||
// "?success=updated". The ID is path-escaped so the target is always
|
||||
// a relative application URL.
|
||||
func redirectToApp(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
appID, suffix string,
|
||||
) {
|
||||
http.Redirect(
|
||||
writer,
|
||||
request,
|
||||
"/apps/"+url.PathEscape(appID)+suffix,
|
||||
http.StatusSeeOther,
|
||||
)
|
||||
}
|
||||
|
||||
// HandleAppNew returns the new app form handler.
|
||||
func (h *Handlers) HandleAppNew() http.HandlerFunc {
|
||||
tmpl := templates.GetParsed()
|
||||
@@ -39,7 +58,9 @@ func (h *Handlers) HandleAppNew() http.HandlerFunc {
|
||||
}
|
||||
|
||||
// HandleAppCreate handles app creation.
|
||||
func (h *Handlers) HandleAppCreate() http.HandlerFunc { //nolint:funlen // validation adds necessary length
|
||||
//
|
||||
//nolint:funlen // validation adds necessary length
|
||||
func (h *Handlers) HandleAppCreate() http.HandlerFunc {
|
||||
tmpl := templates.GetParsed()
|
||||
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
@@ -160,10 +181,14 @@ func (h *Handlers) HandleAppDetail() http.HandlerFunc {
|
||||
}
|
||||
|
||||
webhookURL := "https://" + request.Host + "/webhook/" + application.WebhookSecret
|
||||
deployKey := formatDeployKey(application.SSHPublicKey, application.CreatedAt, application.Name)
|
||||
deployKey := formatDeployKey(
|
||||
application.SSHPublicKey,
|
||||
application.CreatedAt,
|
||||
application.Name,
|
||||
)
|
||||
|
||||
data := h.addGlobals(map[string]any{
|
||||
"App": application,
|
||||
dataKeyApp: application,
|
||||
"EnvVars": envVars,
|
||||
"Labels": labels,
|
||||
"Volumes": volumes,
|
||||
@@ -201,7 +226,7 @@ func (h *Handlers) HandleAppEdit() http.HandlerFunc {
|
||||
}
|
||||
|
||||
data := h.addGlobals(map[string]any{
|
||||
"App": application,
|
||||
dataKeyApp: application,
|
||||
}, request)
|
||||
|
||||
h.renderTemplate(writer, tmpl, "app_edit.html", data)
|
||||
@@ -209,7 +234,7 @@ func (h *Handlers) HandleAppEdit() http.HandlerFunc {
|
||||
}
|
||||
|
||||
// HandleAppUpdate handles app updates.
|
||||
func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // validation adds necessary length
|
||||
func (h *Handlers) HandleAppUpdate() http.HandlerFunc {
|
||||
tmpl := templates.GetParsed()
|
||||
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
@@ -234,8 +259,8 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // valid
|
||||
nameErr := validateAppName(newName)
|
||||
if nameErr != nil {
|
||||
data := h.addGlobals(map[string]any{
|
||||
"App": application,
|
||||
"Error": "Invalid app name: " + nameErr.Error(),
|
||||
dataKeyApp: application,
|
||||
dataKeyError: "Invalid app name: " + nameErr.Error(),
|
||||
}, request)
|
||||
h.renderTemplate(writer, tmpl, "app_edit.html", data)
|
||||
|
||||
@@ -245,8 +270,8 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // valid
|
||||
repoURLErr := validateRepoURL(request.FormValue("repo_url"))
|
||||
if repoURLErr != nil {
|
||||
data := h.addGlobals(map[string]any{
|
||||
"App": application,
|
||||
"Error": "Invalid repository URL: " + repoURLErr.Error(),
|
||||
dataKeyApp: application,
|
||||
dataKeyError: "Invalid repository URL: " + repoURLErr.Error(),
|
||||
}, request)
|
||||
h.renderTemplate(writer, tmpl, "app_edit.html", data)
|
||||
|
||||
@@ -264,8 +289,8 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // valid
|
||||
limitsErr := applyResourceLimits(application, request)
|
||||
if limitsErr != "" {
|
||||
data := h.addGlobals(map[string]any{
|
||||
"App": application,
|
||||
"Error": limitsErr,
|
||||
dataKeyApp: application,
|
||||
dataKeyError: limitsErr,
|
||||
}, request)
|
||||
h.renderTemplate(writer, tmpl, "app_edit.html", data)
|
||||
|
||||
@@ -277,16 +302,15 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // valid
|
||||
h.log.Error("failed to update app", "error", saveErr)
|
||||
|
||||
data := h.addGlobals(map[string]any{
|
||||
"App": application,
|
||||
"Error": "Failed to update app",
|
||||
dataKeyApp: application,
|
||||
dataKeyError: "Failed to update app",
|
||||
}, request)
|
||||
h.renderTemplate(writer, tmpl, "app_edit.html", data)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
redirectURL := "/apps/" + application.ID + "?success=updated"
|
||||
http.Redirect(writer, request, redirectURL, http.StatusSeeOther)
|
||||
redirectToApp(writer, request, application.ID, "?success=updated")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,12 +395,7 @@ func (h *Handlers) HandleAppDeploy() http.HandlerFunc {
|
||||
}
|
||||
}(deployCtx, application)
|
||||
|
||||
http.Redirect(
|
||||
writer,
|
||||
request,
|
||||
"/apps/"+application.ID+"/deployments",
|
||||
http.StatusSeeOther,
|
||||
)
|
||||
redirectToApp(writer, request, application.ID, "/deployments")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,12 +416,7 @@ func (h *Handlers) HandleCancelDeploy() http.HandlerFunc {
|
||||
h.log.Info("deployment cancelled by user", "app", application.Name)
|
||||
}
|
||||
|
||||
http.Redirect(
|
||||
writer,
|
||||
request,
|
||||
"/apps/"+application.ID,
|
||||
http.StatusSeeOther,
|
||||
)
|
||||
redirectToApp(writer, request, application.ID, "")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,12 +435,12 @@ func (h *Handlers) HandleAppRollback() http.HandlerFunc {
|
||||
rollbackErr := h.deploy.Rollback(request.Context(), application)
|
||||
if rollbackErr != nil {
|
||||
h.log.Error("rollback failed", "error", rollbackErr, "app", application.Name)
|
||||
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
|
||||
redirectToApp(writer, request, application.ID, "")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(writer, request, "/apps/"+application.ID+"?success=rolledback", http.StatusSeeOther)
|
||||
redirectToApp(writer, request, application.ID, "?success=rolledback")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,7 +464,7 @@ func (h *Handlers) HandleAppDeployments() http.HandlerFunc {
|
||||
)
|
||||
|
||||
data := h.addGlobals(map[string]any{
|
||||
"App": application,
|
||||
dataKeyApp: application,
|
||||
"Deployments": deployments,
|
||||
}, request)
|
||||
|
||||
@@ -523,7 +537,7 @@ func (h *Handlers) HandleAppLogs() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = writer.Write([]byte(SanitizeLogs(logs))) // #nosec G705 -- logs sanitized, Content-Type is text/plain
|
||||
_, _ = writer.Write([]byte(SanitizeLogs(logs))) // #nosec G705 -- output sanitized
|
||||
}
|
||||
}
|
||||
|
||||
@@ -562,8 +576,8 @@ func (h *Handlers) HandleDeploymentLogsAPI() http.HandlerFunc {
|
||||
}
|
||||
|
||||
response := map[string]any{
|
||||
"logs": logs,
|
||||
"status": deployment.Status,
|
||||
jsonKeyLogs: logs,
|
||||
jsonKeyStatus: deployment.Status,
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(writer).Encode(response)
|
||||
@@ -606,7 +620,7 @@ func (h *Handlers) HandleDeploymentLogDownload() http.HandlerFunc {
|
||||
}
|
||||
|
||||
// Check if file exists — logPath is constructed internally, not from user input
|
||||
_, err := os.Stat(logPath) // #nosec G703 -- path from internal GetLogFilePath, not user input
|
||||
_, err := os.Stat(logPath) // #nosec G703 -- internal path, not user input
|
||||
if os.IsNotExist(err) {
|
||||
http.NotFound(writer, request)
|
||||
|
||||
@@ -626,7 +640,7 @@ func (h *Handlers) HandleDeploymentLogDownload() http.HandlerFunc {
|
||||
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
writer.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
|
||||
|
||||
http.ServeFile(writer, request, logPath)
|
||||
http.ServeFile(writer, request, logPath) // #nosec G703 -- internal path
|
||||
}
|
||||
}
|
||||
|
||||
@@ -650,8 +664,8 @@ func (h *Handlers) HandleContainerLogsAPI() http.HandlerFunc {
|
||||
containerInfo, containerErr := h.docker.FindContainerByAppID(request.Context(), appID)
|
||||
if containerErr != nil || containerInfo == nil {
|
||||
response := map[string]any{
|
||||
"logs": "No container running\n",
|
||||
"status": "stopped",
|
||||
jsonKeyLogs: "No container running\n",
|
||||
jsonKeyStatus: "stopped",
|
||||
}
|
||||
_ = json.NewEncoder(writer).Encode(response)
|
||||
|
||||
@@ -671,8 +685,8 @@ func (h *Handlers) HandleContainerLogsAPI() http.HandlerFunc {
|
||||
)
|
||||
|
||||
response := map[string]any{
|
||||
"logs": "Failed to fetch container logs\n",
|
||||
"status": "error",
|
||||
jsonKeyLogs: "Failed to fetch container logs\n",
|
||||
jsonKeyStatus: "error",
|
||||
}
|
||||
_ = json.NewEncoder(writer).Encode(response)
|
||||
|
||||
@@ -685,8 +699,8 @@ func (h *Handlers) HandleContainerLogsAPI() http.HandlerFunc {
|
||||
}
|
||||
|
||||
response := map[string]any{
|
||||
"logs": SanitizeLogs(logs),
|
||||
"status": status,
|
||||
jsonKeyLogs: SanitizeLogs(logs),
|
||||
jsonKeyStatus: status,
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(writer).Encode(response)
|
||||
@@ -720,7 +734,7 @@ func (h *Handlers) HandleAppStatusAPI() http.HandlerFunc {
|
||||
}
|
||||
|
||||
response := map[string]any{
|
||||
"status": string(application.Status),
|
||||
jsonKeyStatus: string(application.Status),
|
||||
"latestDeploymentID": latestDeploymentID,
|
||||
"latestDeploymentStatus": latestDeploymentStatus,
|
||||
}
|
||||
@@ -757,7 +771,7 @@ func (h *Handlers) HandleRecentDeploymentsAPI() http.HandlerFunc {
|
||||
for _, d := range deployments {
|
||||
deploymentsData = append(deploymentsData, map[string]any{
|
||||
"id": d.ID,
|
||||
"status": string(d.Status),
|
||||
jsonKeyStatus: string(d.Status),
|
||||
"duration": d.Duration(),
|
||||
"shortCommit": d.ShortCommit(),
|
||||
"finishedAtISO": d.FinishedAtISO(),
|
||||
@@ -799,7 +813,7 @@ func (h *Handlers) handleContainerAction(
|
||||
|
||||
containerInfo, containerErr := h.docker.FindContainerByAppID(ctx, appID)
|
||||
if containerErr != nil || containerInfo == nil {
|
||||
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
|
||||
redirectToApp(writer, request, appID, "")
|
||||
|
||||
return
|
||||
}
|
||||
@@ -832,7 +846,7 @@ func (h *Handlers) handleContainerAction(
|
||||
"action", action, "app", application.Name, "container", containerID)
|
||||
}
|
||||
|
||||
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
|
||||
redirectToApp(writer, request, appID, "")
|
||||
}
|
||||
|
||||
// HandleAppRestart handles restarting an app's container.
|
||||
@@ -886,7 +900,7 @@ func (h *Handlers) addKeyValueToApp(
|
||||
value := request.FormValue("value")
|
||||
|
||||
if key == "" || value == "" {
|
||||
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
|
||||
redirectToApp(writer, request, application.ID, "")
|
||||
|
||||
return
|
||||
}
|
||||
@@ -896,7 +910,7 @@ func (h *Handlers) addKeyValueToApp(
|
||||
h.log.Error("failed to add key-value pair", "error", saveErr)
|
||||
}
|
||||
|
||||
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
|
||||
redirectToApp(writer, request, application.ID, "")
|
||||
}
|
||||
|
||||
// envPairJSON represents a key-value pair in the JSON request body.
|
||||
@@ -957,7 +971,7 @@ func (h *Handlers) HandleEnvVarSave() http.HandlerFunc {
|
||||
decodeErr := json.NewDecoder(request.Body).Decode(&pairs)
|
||||
if decodeErr != nil {
|
||||
h.respondJSON(writer, request, map[string]string{
|
||||
"error": "invalid request body",
|
||||
jsonKeyError: "invalid request body",
|
||||
}, http.StatusBadRequest)
|
||||
|
||||
return
|
||||
@@ -966,7 +980,7 @@ func (h *Handlers) HandleEnvVarSave() http.HandlerFunc {
|
||||
modelPairs, validationErr := validateEnvPairs(pairs)
|
||||
if validationErr != "" {
|
||||
h.respondJSON(writer, request, map[string]string{
|
||||
"error": validationErr,
|
||||
jsonKeyError: validationErr,
|
||||
}, http.StatusBadRequest)
|
||||
|
||||
return
|
||||
@@ -978,7 +992,7 @@ func (h *Handlers) HandleEnvVarSave() http.HandlerFunc {
|
||||
if replaceErr != nil {
|
||||
h.log.Error("failed to replace env vars", "error", replaceErr)
|
||||
h.respondJSON(writer, request, map[string]string{
|
||||
"error": "failed to save environment variables",
|
||||
jsonKeyError: "failed to save environment variables",
|
||||
}, http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
@@ -1006,32 +1020,77 @@ func (h *Handlers) HandleLabelAdd() http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// deleteAppResource handles deletion of an app-owned resource (label,
|
||||
// volume, or port) identified by an int64 URL parameter. The
|
||||
// deleteByID closure reports whether the resource was found to belong
|
||||
// to the app, and returns the deletion error if one occurred.
|
||||
func (h *Handlers) deleteAppResource(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
idParam, logName string,
|
||||
deleteByID deleteByIDFunc,
|
||||
) {
|
||||
appID := chi.URLParam(request, "id")
|
||||
idStr := chi.URLParam(request, idParam)
|
||||
|
||||
id, parseErr := strconv.ParseInt(idStr, 10, 64)
|
||||
if parseErr != nil {
|
||||
http.NotFound(writer, request)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
found, deleteErr := deleteByID(request.Context(), appID, id)
|
||||
if !found {
|
||||
http.NotFound(writer, request)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if deleteErr != nil {
|
||||
h.log.Error("failed to delete "+logName, "error", deleteErr)
|
||||
}
|
||||
|
||||
redirectToApp(writer, request, appID, "")
|
||||
}
|
||||
|
||||
// deleteByIDFunc looks up an app-owned resource by ID and deletes it
|
||||
// when it belongs to the given app. It reports whether the resource
|
||||
// was found, and returns lookup or deletion errors.
|
||||
type deleteByIDFunc func(ctx context.Context, appID string, id int64) (bool, error)
|
||||
|
||||
// makeDeleteByID builds a deleteByIDFunc from a model's find
|
||||
// function, its app-ID accessor, and its delete method.
|
||||
func makeDeleteByID[T any](
|
||||
db *database.Database,
|
||||
find func(context.Context, *database.Database, int64) (*T, error),
|
||||
appIDOf func(*T) string,
|
||||
del func(*T, context.Context) error,
|
||||
) deleteByIDFunc {
|
||||
return func(ctx context.Context, appID string, id int64) (bool, error) {
|
||||
resource, findErr := find(ctx, db, id)
|
||||
if findErr != nil {
|
||||
return false, findErr
|
||||
}
|
||||
|
||||
if resource == nil || appIDOf(resource) != appID {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, del(resource, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleLabelDelete handles deleting a label.
|
||||
func (h *Handlers) HandleLabelDelete() http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
appID := chi.URLParam(request, "id")
|
||||
labelIDStr := chi.URLParam(request, "labelID")
|
||||
|
||||
labelID, parseErr := strconv.ParseInt(labelIDStr, 10, 64)
|
||||
if parseErr != nil {
|
||||
http.NotFound(writer, request)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
label, findErr := models.FindLabel(request.Context(), h.db, labelID)
|
||||
if findErr != nil || label == nil || label.AppID != appID {
|
||||
http.NotFound(writer, request)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
deleteErr := label.Delete(request.Context())
|
||||
if deleteErr != nil {
|
||||
h.log.Error("failed to delete label", "error", deleteErr)
|
||||
}
|
||||
|
||||
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
|
||||
h.deleteAppResource(
|
||||
writer, request, "labelID", "label",
|
||||
makeDeleteByID(h.db, models.FindLabel,
|
||||
func(l *models.Label) string { return l.AppID },
|
||||
(*models.Label).Delete,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1059,12 +1118,7 @@ func (h *Handlers) HandleVolumeAdd() http.HandlerFunc {
|
||||
readOnly := request.FormValue("readonly") == "1"
|
||||
|
||||
if hostPath == "" || containerPath == "" {
|
||||
http.Redirect(
|
||||
writer,
|
||||
request,
|
||||
"/apps/"+application.ID,
|
||||
http.StatusSeeOther,
|
||||
)
|
||||
redirectToApp(writer, request, application.ID, "")
|
||||
|
||||
return
|
||||
}
|
||||
@@ -1072,7 +1126,7 @@ func (h *Handlers) HandleVolumeAdd() http.HandlerFunc {
|
||||
pathErr := validateVolumePaths(hostPath, containerPath)
|
||||
if pathErr != nil {
|
||||
h.log.Error("invalid volume path", "error", pathErr)
|
||||
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
|
||||
redirectToApp(writer, request, application.ID, "")
|
||||
|
||||
return
|
||||
}
|
||||
@@ -1088,36 +1142,20 @@ func (h *Handlers) HandleVolumeAdd() http.HandlerFunc {
|
||||
h.log.Error("failed to add volume", "error", saveErr)
|
||||
}
|
||||
|
||||
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
|
||||
redirectToApp(writer, request, application.ID, "")
|
||||
}
|
||||
}
|
||||
|
||||
// HandleVolumeDelete handles deleting a volume mount.
|
||||
func (h *Handlers) HandleVolumeDelete() http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
appID := chi.URLParam(request, "id")
|
||||
volumeIDStr := chi.URLParam(request, "volumeID")
|
||||
|
||||
volumeID, parseErr := strconv.ParseInt(volumeIDStr, 10, 64)
|
||||
if parseErr != nil {
|
||||
http.NotFound(writer, request)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
volume, findErr := models.FindVolume(request.Context(), h.db, volumeID)
|
||||
if findErr != nil || volume == nil || volume.AppID != appID {
|
||||
http.NotFound(writer, request)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
deleteErr := volume.Delete(request.Context())
|
||||
if deleteErr != nil {
|
||||
h.log.Error("failed to delete volume", "error", deleteErr)
|
||||
}
|
||||
|
||||
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
|
||||
h.deleteAppResource(
|
||||
writer, request, "volumeID", "volume",
|
||||
makeDeleteByID(h.db, models.FindVolume,
|
||||
func(v *models.Volume) string { return v.AppID },
|
||||
(*models.Volume).Delete,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1145,7 +1183,7 @@ func (h *Handlers) HandlePortAdd() http.HandlerFunc {
|
||||
request.FormValue("container_port"),
|
||||
)
|
||||
if !valid {
|
||||
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
|
||||
redirectToApp(writer, request, application.ID, "")
|
||||
|
||||
return
|
||||
}
|
||||
@@ -1166,7 +1204,7 @@ func (h *Handlers) HandlePortAdd() http.HandlerFunc {
|
||||
h.log.Error("failed to save port", "error", saveErr)
|
||||
}
|
||||
|
||||
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
|
||||
redirectToApp(writer, request, application.ID, "")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1190,29 +1228,13 @@ func parsePortValues(hostPortStr, containerPortStr string) (int, int, bool) {
|
||||
// HandlePortDelete handles deleting a port mapping.
|
||||
func (h *Handlers) HandlePortDelete() http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
appID := chi.URLParam(request, "id")
|
||||
portIDStr := chi.URLParam(request, "portID")
|
||||
|
||||
portID, parseErr := strconv.ParseInt(portIDStr, 10, 64)
|
||||
if parseErr != nil {
|
||||
http.NotFound(writer, request)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
port, findErr := models.FindPort(request.Context(), h.db, portID)
|
||||
if findErr != nil || port == nil || port.AppID != appID {
|
||||
http.NotFound(writer, request)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
deleteErr := port.Delete(request.Context())
|
||||
if deleteErr != nil {
|
||||
h.log.Error("failed to delete port", "error", deleteErr)
|
||||
}
|
||||
|
||||
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
|
||||
h.deleteAppResource(
|
||||
writer, request, "portID", "port",
|
||||
makeDeleteByID(h.db, models.FindPort,
|
||||
func(p *models.Port) string { return p.AppID },
|
||||
(*models.Port).Delete,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1274,7 +1296,7 @@ func (h *Handlers) HandleLabelEdit() http.HandlerFunc {
|
||||
value := request.FormValue("value")
|
||||
|
||||
if key == "" || value == "" {
|
||||
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
|
||||
redirectToApp(writer, request, appID, "")
|
||||
|
||||
return
|
||||
}
|
||||
@@ -1287,7 +1309,7 @@ func (h *Handlers) HandleLabelEdit() http.HandlerFunc {
|
||||
h.log.Error("failed to update label", "error", saveErr)
|
||||
}
|
||||
|
||||
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
|
||||
redirectToApp(writer, request, appID, "")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1323,7 +1345,7 @@ func (h *Handlers) HandleVolumeEdit() http.HandlerFunc {
|
||||
readOnly := request.FormValue("readonly") == "1"
|
||||
|
||||
if hostPath == "" || containerPath == "" {
|
||||
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
|
||||
redirectToApp(writer, request, appID, "")
|
||||
|
||||
return
|
||||
}
|
||||
@@ -1331,7 +1353,7 @@ func (h *Handlers) HandleVolumeEdit() http.HandlerFunc {
|
||||
pathErr := validateVolumePaths(hostPath, containerPath)
|
||||
if pathErr != nil {
|
||||
h.log.Error("invalid volume path", "error", pathErr)
|
||||
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
|
||||
redirectToApp(writer, request, appID, "")
|
||||
|
||||
return
|
||||
}
|
||||
@@ -1345,7 +1367,7 @@ func (h *Handlers) HandleVolumeEdit() http.HandlerFunc {
|
||||
h.log.Error("failed to update volume", "error", saveErr)
|
||||
}
|
||||
|
||||
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
|
||||
redirectToApp(writer, request, appID, "")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1389,8 +1411,9 @@ func optionalNullString(s string) sql.NullString {
|
||||
return sql.NullString{}
|
||||
}
|
||||
|
||||
// applyResourceLimits parses CPU and memory limit form values and applies them to the app.
|
||||
// Returns an error message string if validation fails, or empty string on success.
|
||||
// applyResourceLimits parses CPU and memory limit form values and
|
||||
// applies them to the app. Returns an error message string if
|
||||
// validation fails, or empty string on success.
|
||||
func applyResourceLimits(application *models.App, request *http.Request) string {
|
||||
cpuLimit, cpuErr := parseOptionalFloat64(request.FormValue("cpu_limit"))
|
||||
if cpuErr != nil {
|
||||
@@ -1425,7 +1448,8 @@ func memoryUnitMultiplier(suffix byte) int64 {
|
||||
}
|
||||
|
||||
// parseOptionalFloat64 parses an optional float64 form field.
|
||||
// Returns a valid NullFloat64 if the string is non-empty and parses to a positive number.
|
||||
// Returns a valid NullFloat64 if the string is non-empty and parses
|
||||
// to a positive number.
|
||||
// Returns an empty NullFloat64 if the string is empty.
|
||||
// Returns an error if the string is non-empty but invalid or non-positive.
|
||||
func parseOptionalFloat64(s string) (sql.NullFloat64, error) {
|
||||
@@ -1447,7 +1471,8 @@ func parseOptionalFloat64(s string) (sql.NullFloat64, error) {
|
||||
}
|
||||
|
||||
// parseOptionalMemoryBytes parses an optional memory limit string into bytes.
|
||||
// Accepts plain bytes (e.g. "536870912") or suffixed values (e.g. "512m", "1g", "256k").
|
||||
// Accepts plain bytes (e.g. "536870912") or suffixed values
|
||||
// (e.g. "512m", "1g", "256k").
|
||||
// Returns a valid NullInt64 with bytes if non-empty, empty NullInt64 if blank.
|
||||
func parseOptionalMemoryBytes(s string) (sql.NullInt64, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
|
||||
@@ -21,8 +21,16 @@ func TestValidateAppName(t *testing.T) {
|
||||
{"empty", "", true},
|
||||
{"single char", "a", true},
|
||||
{"too long", "a" + string(make([]byte, 63)), true},
|
||||
{"exactly 63 chars", "a23456789012345678901234567890123456789012345678901234567890123", false},
|
||||
{"64 chars", "a234567890123456789012345678901234567890123456789012345678901234", true},
|
||||
{
|
||||
"exactly 63 chars",
|
||||
"a23456789012345678901234567890123456789012345678901234567890123",
|
||||
false,
|
||||
},
|
||||
{
|
||||
"64 chars",
|
||||
"a234567890123456789012345678901234567890123456789012345678901234",
|
||||
true,
|
||||
},
|
||||
{"uppercase", "MyApp", true},
|
||||
{"spaces", "my app", true},
|
||||
{"starts with hyphen", "-myapp", true},
|
||||
|
||||
@@ -22,6 +22,19 @@ import (
|
||||
"sneak.berlin/go/upaas/templates"
|
||||
)
|
||||
|
||||
// Template data keys shared across handlers.
|
||||
const (
|
||||
dataKeyApp = "App"
|
||||
dataKeyError = "Error"
|
||||
)
|
||||
|
||||
// JSON response keys shared across handlers.
|
||||
const (
|
||||
jsonKeyError = "error"
|
||||
jsonKeyLogs = "logs"
|
||||
jsonKeyStatus = "status"
|
||||
)
|
||||
|
||||
// Params contains dependencies for Handlers.
|
||||
type Params struct {
|
||||
fx.In
|
||||
|
||||
@@ -32,6 +32,11 @@ import (
|
||||
"sneak.berlin/go/upaas/internal/service/webhook"
|
||||
)
|
||||
|
||||
const (
|
||||
branchMain = "main"
|
||||
paramSecret = "secret"
|
||||
)
|
||||
|
||||
type testContext struct {
|
||||
handlers *handlers.Handlers
|
||||
database *database.Database
|
||||
@@ -193,7 +198,8 @@ func TestHandleHealthCheck(t *testing.T) {
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
request := httptest.NewRequest(
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
http.MethodGet,
|
||||
"/.well-known/healthcheck.json",
|
||||
nil,
|
||||
@@ -210,6 +216,26 @@ func TestHandleHealthCheck(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// assertPageRenders serves a GET request for path with the given
|
||||
// handler and asserts a 200 response containing want.
|
||||
func assertPageRenders(
|
||||
t *testing.T,
|
||||
handler http.Handler,
|
||||
path, want string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, path, nil,
|
||||
)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
assert.Contains(t, recorder.Body.String(), want)
|
||||
}
|
||||
|
||||
func TestHandleSetupGET(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -217,15 +243,7 @@ func TestHandleSetupGET(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/setup", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := testCtx.handlers.HandleSetupGET()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
assert.Contains(t, recorder.Body.String(), "setup")
|
||||
assertPageRenders(t, testCtx.handlers.HandleSetupGET(), "/setup", "setup")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -237,7 +255,8 @@ func createSetupFormRequest(
|
||||
form.Set("password", password)
|
||||
form.Set("password_confirm", confirm)
|
||||
|
||||
request := httptest.NewRequest(
|
||||
request := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodPost,
|
||||
"/setup",
|
||||
strings.NewReader(form.Encode()),
|
||||
@@ -314,15 +333,7 @@ func TestHandleLoginGET(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := testCtx.handlers.HandleLoginGET()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
assert.Contains(t, recorder.Body.String(), "login")
|
||||
assertPageRenders(t, testCtx.handlers.HandleLoginGET(), "/login", "login")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -331,7 +342,8 @@ func createLoginFormRequest(username, password string) *http.Request {
|
||||
form.Set("username", username)
|
||||
form.Set("password", password)
|
||||
|
||||
request := httptest.NewRequest(
|
||||
request := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodPost,
|
||||
"/login",
|
||||
strings.NewReader(form.Encode()),
|
||||
@@ -395,7 +407,9 @@ func TestHandleDashboard(t *testing.T) {
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, "/", nil,
|
||||
)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := testCtx.handlers.HandleDashboard()
|
||||
@@ -413,7 +427,9 @@ func TestHandleDashboard(t *testing.T) {
|
||||
// Create an app so the template iterates over AppStats and hits .CSRFField
|
||||
createTestApp(t, testCtx, "csrf-test-app")
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, "/", nil,
|
||||
)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := testCtx.handlers.HandleDashboard()
|
||||
@@ -433,7 +449,9 @@ func TestHandleAppNew(t *testing.T) {
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/apps/new", nil)
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, "/apps/new", nil,
|
||||
)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := testCtx.handlers.HandleAppNew()
|
||||
@@ -472,7 +490,7 @@ func createTestApp(
|
||||
app.CreateAppInput{
|
||||
Name: name,
|
||||
RepoURL: "git@example.com:user/" + name + ".git",
|
||||
Branch: "main",
|
||||
Branch: branchMain,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
@@ -493,7 +511,7 @@ func TestHandleWebhookRejectsOversizedBody(t *testing.T) {
|
||||
app.CreateAppInput{
|
||||
Name: "oversize-test-app",
|
||||
RepoURL: "git@example.com:user/repo.git",
|
||||
Branch: "main",
|
||||
Branch: branchMain,
|
||||
},
|
||||
)
|
||||
require.NoError(t, createErr)
|
||||
@@ -501,14 +519,15 @@ func TestHandleWebhookRejectsOversizedBody(t *testing.T) {
|
||||
// Create a body larger than 1MB - it should be silently truncated
|
||||
// and the webhook should still process (or fail gracefully on parse)
|
||||
largePayload := strings.Repeat("x", 2*1024*1024) // 2MB
|
||||
request := httptest.NewRequest(
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
http.MethodPost,
|
||||
"/webhook/"+createdApp.WebhookSecret,
|
||||
strings.NewReader(largePayload),
|
||||
)
|
||||
request = addChiURLParams(
|
||||
request,
|
||||
map[string]string{"secret": createdApp.WebhookSecret},
|
||||
map[string]string{paramSecret: createdApp.WebhookSecret},
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("X-Gitea-Event", "push")
|
||||
@@ -544,7 +563,8 @@ func testOwnershipVerification(t *testing.T, cfg ownedResourceTestConfig) {
|
||||
|
||||
resourceID := cfg.createFn(t, testCtx, app1)
|
||||
|
||||
request := httptest.NewRequest(
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
http.MethodPost,
|
||||
cfg.deletePath(app2.ID, resourceID),
|
||||
nil,
|
||||
@@ -583,7 +603,8 @@ func TestHandleEnvVarSaveBulk(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
|
||||
|
||||
request := httptest.NewRequest(
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
http.MethodPost,
|
||||
"/apps/"+createdApp.ID+"/env",
|
||||
strings.NewReader(body),
|
||||
@@ -625,7 +646,8 @@ func TestHandleEnvVarSaveAppNotFound(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
|
||||
|
||||
request := httptest.NewRequest(
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
http.MethodPost,
|
||||
"/apps/nonexistent-id/env",
|
||||
strings.NewReader(body),
|
||||
@@ -651,7 +673,8 @@ func TestHandleEnvVarSaveEmptyKeyRejected(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
|
||||
|
||||
request := httptest.NewRequest(
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
http.MethodPost,
|
||||
"/apps/"+createdApp.ID+"/env",
|
||||
strings.NewReader(body),
|
||||
@@ -673,12 +696,14 @@ func TestHandleEnvVarSaveDuplicateKeyRejected(t *testing.T) {
|
||||
createdApp := createTestApp(t, testCtx, "envvar-dedup-app")
|
||||
|
||||
// Send two entries with the same key — should be rejected
|
||||
body := `[{"key":"FOO","value":"first"},{"key":"BAR","value":"bar"},{"key":"FOO","value":"second"}]`
|
||||
body := `[{"key":"FOO","value":"first"},{"key":"BAR","value":"bar"},` +
|
||||
`{"key":"FOO","value":"second"}]`
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
|
||||
|
||||
request := httptest.NewRequest(
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
http.MethodPost,
|
||||
"/apps/"+createdApp.ID+"/env",
|
||||
strings.NewReader(body),
|
||||
@@ -716,7 +741,8 @@ func TestHandleEnvVarSaveCrossAppIsolation(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
|
||||
|
||||
request := httptest.NewRequest(
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
http.MethodPost,
|
||||
"/apps/"+appA.ID+"/env",
|
||||
strings.NewReader(body),
|
||||
@@ -779,7 +805,8 @@ func TestHandleEnvVarSaveBodySizeLimit(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
|
||||
|
||||
request := httptest.NewRequest(
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
http.MethodPost,
|
||||
"/apps/"+createdApp.ID+"/env",
|
||||
strings.NewReader(sb.String()),
|
||||
@@ -848,7 +875,8 @@ func TestDeleteVolumeOwnershipVerification(t *testing.T) {
|
||||
require.NoError(t, volume.Save(context.Background()))
|
||||
|
||||
// Try to delete app1's volume using app2's URL path
|
||||
request := httptest.NewRequest(
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
http.MethodPost,
|
||||
"/apps/"+app2.ID+"/volumes/"+strconv.FormatInt(volume.ID, 10)+"/delete",
|
||||
nil,
|
||||
@@ -889,7 +917,8 @@ func TestDeletePortOwnershipVerification(t *testing.T) {
|
||||
require.NoError(t, port.Save(context.Background()))
|
||||
|
||||
// Try to delete app1's port using app2's URL path
|
||||
request := httptest.NewRequest(
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
http.MethodPost,
|
||||
"/apps/"+app2.ID+"/ports/"+strconv.FormatInt(port.ID, 10)+"/delete",
|
||||
nil,
|
||||
@@ -930,7 +959,8 @@ func TestHandleEnvVarSaveEmptyClears(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
|
||||
|
||||
request := httptest.NewRequest(
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
http.MethodPost,
|
||||
"/apps/"+createdApp.ID+"/env",
|
||||
strings.NewReader("[]"),
|
||||
@@ -979,7 +1009,8 @@ func TestHandleVolumeAddValidatesPaths(t *testing.T) {
|
||||
form.Set("host_path", tt.hostPath)
|
||||
form.Set("container_path", tt.containerPath)
|
||||
|
||||
request := httptest.NewRequest(
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
http.MethodPost,
|
||||
"/apps/"+createdApp.ID+"/volumes",
|
||||
strings.NewReader(form.Encode()),
|
||||
@@ -1016,7 +1047,8 @@ func TestHandleVolumeAddValidatesPaths(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestSetupRequiredExemptsHealthAndStaticAndAPI verifies that the SetupRequired
|
||||
// middleware allows /health, /s/*, and /api/* paths through even when setup is required.
|
||||
// middleware allows /health, /s/*, and /api/* paths through even when setup is
|
||||
// required.
|
||||
func TestSetupRequiredExemptsHealthAndStaticAndAPI(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1032,13 +1064,21 @@ func TestSetupRequiredExemptsHealthAndStaticAndAPI(t *testing.T) {
|
||||
|
||||
wrapped := mw(okHandler)
|
||||
|
||||
exemptPaths := []string{"/health", "/s/style.css", "/s/js/app.js", "/api/v1/apps", "/api/v1/login"}
|
||||
exemptPaths := []string{
|
||||
"/health",
|
||||
"/s/style.css",
|
||||
"/s/js/app.js",
|
||||
"/api/v1/apps",
|
||||
"/api/v1/login",
|
||||
}
|
||||
|
||||
for _, path := range exemptPaths {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req := httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, path, nil,
|
||||
)
|
||||
rr := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(rr, req)
|
||||
|
||||
@@ -1051,7 +1091,9 @@ func TestSetupRequiredExemptsHealthAndStaticAndAPI(t *testing.T) {
|
||||
t.Run("non-exempt redirects", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req := httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, "/", nil,
|
||||
)
|
||||
rr := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(rr, req)
|
||||
|
||||
@@ -1067,7 +1109,8 @@ func TestHandleCancelDeployRedirects(t *testing.T) {
|
||||
|
||||
createdApp := createTestApp(t, testCtx, "cancel-deploy-app")
|
||||
|
||||
request := httptest.NewRequest(
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
http.MethodPost,
|
||||
"/apps/"+createdApp.ID+"/deployments/cancel",
|
||||
nil,
|
||||
@@ -1087,7 +1130,8 @@ func TestHandleCancelDeployReturns404ForUnknownApp(t *testing.T) {
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
request := httptest.NewRequest(
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
http.MethodPost,
|
||||
"/apps/nonexistent/deployments/cancel",
|
||||
nil,
|
||||
@@ -1108,12 +1152,16 @@ func TestHandleWebhookReturns404ForUnknownSecret(t *testing.T) {
|
||||
|
||||
webhookURL := "/webhook/unknown-secret"
|
||||
payload := `{"ref": "refs/heads/main"}`
|
||||
request := httptest.NewRequest(
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
http.MethodPost,
|
||||
webhookURL,
|
||||
strings.NewReader(payload),
|
||||
)
|
||||
request = addChiURLParams(request, map[string]string{"secret": "unknown-secret"})
|
||||
request = addChiURLParams(
|
||||
request,
|
||||
map[string]string{paramSecret: "unknown-secret"},
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("X-Gitea-Event", "push")
|
||||
|
||||
@@ -1136,21 +1184,22 @@ func TestHandleWebhookProcessesValidWebhook(t *testing.T) {
|
||||
app.CreateAppInput{
|
||||
Name: "webhook-test-app",
|
||||
RepoURL: "git@example.com:user/repo.git",
|
||||
Branch: "main",
|
||||
Branch: branchMain,
|
||||
},
|
||||
)
|
||||
require.NoError(t, createErr)
|
||||
|
||||
payload := `{"ref": "refs/heads/main", "after": "abc123"}`
|
||||
webhookURL := "/webhook/" + createdApp.WebhookSecret
|
||||
request := httptest.NewRequest(
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(),
|
||||
http.MethodPost,
|
||||
webhookURL,
|
||||
strings.NewReader(payload),
|
||||
)
|
||||
request = addChiURLParams(
|
||||
request,
|
||||
map[string]string{"secret": createdApp.WebhookSecret},
|
||||
map[string]string{paramSecret: createdApp.WebhookSecret},
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("X-Gitea-Event", "push")
|
||||
|
||||
@@ -16,7 +16,9 @@ func TestRenderTemplateBuffersOutput(t *testing.T) {
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
// The setup page is simple and has no DB dependencies
|
||||
request := httptest.NewRequest(http.MethodGet, "/setup", nil)
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, "/setup", nil,
|
||||
)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := testCtx.handlers.HandleSetupGET()
|
||||
@@ -39,7 +41,7 @@ func TestDashboardRenderTemplateBuffersOutput(t *testing.T) {
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := testCtx.handlers.HandleDashboard()
|
||||
@@ -59,7 +61,9 @@ func TestLoginRenderTemplateBuffersOutput(t *testing.T) {
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||
request := httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, "/login", nil,
|
||||
)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := testCtx.handlers.HandleLoginGET()
|
||||
|
||||
@@ -11,13 +11,17 @@ import (
|
||||
var (
|
||||
errRepoURLEmpty = errors.New("repository URL must not be empty")
|
||||
errRepoURLScheme = errors.New("file:// URLs are not allowed for security reasons")
|
||||
errRepoURLInvalid = errors.New("repository URL must use https://, http://, ssh://, git://, or git@host:path format")
|
||||
errRepoURLNoHost = errors.New("repository URL must include a host")
|
||||
errRepoURLNoPath = errors.New("repository URL must include a path")
|
||||
errRepoURLInvalid = errors.New(
|
||||
"repository URL must use https://, http://, ssh://, git://, " +
|
||||
"or git@host:path format",
|
||||
)
|
||||
errRepoURLNoHost = errors.New("repository URL must include a host")
|
||||
errRepoURLNoPath = errors.New("repository URL must include a path")
|
||||
)
|
||||
|
||||
// scpLikeRepoRe matches SCP-like git URLs: git@host:path (e.g. git@github.com:user/repo.git).
|
||||
// Only the "git" user is allowed, as that is the standard for SSH deploy keys.
|
||||
// scpLikeRepoRe matches SCP-like git URLs: git@host:path
|
||||
// (e.g. git@github.com:user/repo.git). Only the "git" user is allowed,
|
||||
// as that is the standard for SSH deploy keys.
|
||||
var scpLikeRepoRe = regexp.MustCompile(`^git@[a-zA-Z0-9._-]+:.+$`)
|
||||
|
||||
// allowedRepoSchemes lists the URL schemes accepted for repository URLs.
|
||||
@@ -30,7 +34,8 @@ var allowedRepoSchemes = map[string]bool{
|
||||
"git": true,
|
||||
}
|
||||
|
||||
// validateRepoURL checks that the given repository URL is valid and uses an allowed scheme.
|
||||
// validateRepoURL checks that the given repository URL is valid and
|
||||
// uses an allowed scheme.
|
||||
func validateRepoURL(repoURL string) error {
|
||||
if strings.TrimSpace(repoURL) == "" {
|
||||
return errRepoURLEmpty
|
||||
|
||||
@@ -22,7 +22,11 @@ func TestValidateRepoURL(t *testing.T) {
|
||||
{name: "SCP-like URL", url: "git@github.com:user/repo.git", wantErr: false},
|
||||
{name: "SCP-like with dots", url: "git@git.example.com:org/repo.git", wantErr: false},
|
||||
{name: "https without .git", url: "https://github.com/user/repo", wantErr: false},
|
||||
{name: "https with port", url: "https://git.example.com:8443/user/repo.git", wantErr: false},
|
||||
{
|
||||
name: "https with port",
|
||||
url: "https://git.example.com:8443/user/repo.git",
|
||||
wantErr: false,
|
||||
},
|
||||
|
||||
// Invalid URLs
|
||||
{name: "empty string", url: "", wantErr: true},
|
||||
@@ -37,10 +41,22 @@ func TestValidateRepoURL(t *testing.T) {
|
||||
{name: "no path https", url: "https://github.com", wantErr: true},
|
||||
{name: "no path https trailing slash", url: "https://github.com/", wantErr: true},
|
||||
{name: "SCP-like non-git user", url: "root@github.com:user/repo.git", wantErr: true},
|
||||
{name: "SCP-like arbitrary user", url: "admin@github.com:user/repo.git", wantErr: true},
|
||||
{
|
||||
name: "SCP-like arbitrary user",
|
||||
url: "admin@github.com:user/repo.git",
|
||||
wantErr: true,
|
||||
},
|
||||
{name: "path traversal SCP", url: "git@github.com:../../etc/passwd", wantErr: true},
|
||||
{name: "path traversal https", url: "https://github.com/user/../../../etc/passwd", wantErr: true},
|
||||
{name: "path traversal in middle", url: "https://github.com/user/repo/../secret", wantErr: true},
|
||||
{
|
||||
name: "path traversal https",
|
||||
url: "https://github.com/user/../../../etc/passwd",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "path traversal in middle",
|
||||
url: "https://github.com/user/repo/../secret",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
|
||||
@@ -5,8 +5,11 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ansiEscapePattern matches ANSI escape sequences (CSI, OSC, and single-character escapes).
|
||||
var ansiEscapePattern = regexp.MustCompile(`(\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[^[\]])`)
|
||||
// ansiEscapePattern matches ANSI escape sequences (CSI, OSC, and
|
||||
// single-character escapes).
|
||||
var ansiEscapePattern = regexp.MustCompile(
|
||||
`(\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[^[\]])`,
|
||||
)
|
||||
|
||||
// SanitizeLogs strips ANSI escape sequences and non-printable control characters
|
||||
// from container log output. Newlines (\n), carriage returns (\r), and tabs (\t)
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"sneak.berlin/go/upaas/internal/handlers"
|
||||
)
|
||||
|
||||
func TestSanitizeLogs(t *testing.T) { //nolint:funlen // table-driven tests
|
||||
func TestSanitizeLogs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
|
||||
@@ -55,8 +55,8 @@ func (h *Handlers) renderSetupError(
|
||||
errorMsg string,
|
||||
) {
|
||||
data := h.addGlobals(map[string]any{
|
||||
"Username": username,
|
||||
"Error": errorMsg,
|
||||
"Username": username,
|
||||
dataKeyError: errorMsg,
|
||||
}, request)
|
||||
h.renderTemplate(writer, tmpl, "setup.html", data)
|
||||
}
|
||||
|
||||
@@ -47,8 +47,8 @@ func (h *Handlers) HandleAppWebhookEvents() http.HandlerFunc {
|
||||
}
|
||||
|
||||
data := h.addGlobals(map[string]any{
|
||||
"App": application,
|
||||
"Events": events,
|
||||
dataKeyApp: application,
|
||||
"Events": events,
|
||||
}, request)
|
||||
|
||||
h.renderTemplate(writer, tmpl, "webhook_events.html", data)
|
||||
|
||||
Reference in New Issue
Block a user