Compare commits

..

1 Commits

Author SHA1 Message Date
21642900e6 fix: resolve all 47 noctx lint findings in tests
All checks were successful
Check / check (pull_request) Successful in 3m10s
Replace every httptest.NewRequest call with
httptest.NewRequestWithContext using the test's t.Context(). Thread
t *testing.T through the createSetupFormRequest and
createLoginFormRequest helpers so they can supply a context.

make lint under golangci-lint 2.12.2 drops from 94 findings to 47
(remaining: 23 gosec, 24 goconst, tracked in #176/#177/#178). make
test and make fmt-check pass unchanged.

Closes #175
2026-08-07 16:47:16 +00:00
40 changed files with 782 additions and 1101 deletions

View File

@@ -1,9 +1,5 @@
version: "2"
# Config schema uses the golangci-lint v2 layout (settings live under
# linters.settings, not top-level linters-settings) so that the
# thresholds below are actually applied by golangci-lint >= v2.
run:
timeout: 5m
modules-download-mode: readonly
@@ -18,17 +14,19 @@ linters:
- wsl # Deprecated, replaced by wsl_v5
- wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go
settings:
lll:
line-length: 88
funlen:
lines: 80
statements: 50
cyclop:
max-complexity: 15
dupl:
threshold: 100
linters-settings:
lll:
line-length: 88
funlen:
lines: 80
statements: 50
cyclop:
max-complexity: 15
dupl:
threshold: 100
issues:
exclude-use-default: false
max-issues-per-linter: 0
max-same-issues: 0

View File

@@ -1,6 +1,6 @@
# Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
# golangci/golangci-lint:v2.10.1
FROM golangci/golangci-lint@sha256:ea84d14c2fef724411be7dc45e09e6ef721d748315252b02df19a7e3113ee763 AS lint
WORKDIR /src
COPY go.mod go.sum ./

30
TODO.md
View File

@@ -10,20 +10,23 @@
# Status
1.0+. Tagged 1.0.0 on 2026-02-26; 8 commits on main since. `make check`
is green as of the golangci-lint v2.12.2 update.
1.0+. Tagged 1.0.0 on 2026-02-26. Policy violation: main currently
fails make check under golangci-lint >= 2.12 (47 lint issues remaining:
23 gosec, 24 goconst), so the tree is out of compliance until fixed. CI
(Dockerfile lint stage, pinned golangci-lint v2.10.1) is green; the pin
bump is tracked in issue #179. The road to release 1.1.0 is tracked in
Gitea issues #175-#182 (milestone 1.1.0).
# Next Step
Confirm `.gitea/workflows/check.yml` gates merges on `make check` so
main cannot regress.
Fix the 22 gosec G710 open-redirect findings in
internal/handlers/app.go (issue #176) by validating app IDs in a
shared redirect helper.
# Completed Steps
- 2026-08-07: Updated golangci-lint to v2.12.2 (canonical
`.golangci.yml`, `Dockerfile` lint stage pin, `script/bootstrap`
release-archive pins) and fixed all resulting lint findings (noctx,
gosec, goconst, lll, dupl, nolintlint); `make check` green.
- 2026-08-07: Fixed all 47 noctx lint findings: tests now use
httptest.NewRequestWithContext with t.Context() (#175).
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
Makefile shims, README Entrypoints section
- 2026-03-11: Monolithic env var editing with bulk save (#158).
@@ -46,4 +49,15 @@ main cannot regress.
# Future Steps
- Get main green (compliance, ordered):
- Fix 22 gosec G710 findings (Next Step, #176).
- Fix 1 gosec G703 finding (#177).
- Fix 24 goconst findings (#178).
- Bump Dockerfile golangci-lint pin to v2.12.x (#179).
- Run make check clean on main and keep it green; main must always
pass.
- Confirm .gitea/workflows/check.yml gates merges on make check so main
cannot regress (#180).
- Deploy to fsn1app1 and verify end-to-end (#181), then tag 1.1.0
(#182).
- Resume feature work only after main is green.

View File

@@ -45,7 +45,7 @@ type Config struct {
Port int
Debug bool
DataDir string
HostDataDir string // Host path for DataDir (Docker bind mounts in container)
HostDataDir string // Host path for DataDir (for Docker bind mounts when running in container)
DockerHost string
SentryDSN string
MaintenanceMode bool

View File

@@ -178,8 +178,7 @@ func HashWebhookSecret(secret string) string {
func (d *Database) backfillWebhookSecretHashes(ctx context.Context) error {
rows, err := d.database.QueryContext(ctx,
"SELECT id, webhook_secret FROM apps"+
" WHERE webhook_secret_hash = '' AND webhook_secret != ''")
"SELECT id, webhook_secret FROM apps WHERE webhook_secret_hash = '' AND webhook_secret != ''")
if err != nil {
return fmt.Errorf("querying apps for backfill: %w", err)
}

View File

@@ -32,10 +32,7 @@ var ErrInvalidMigrationFilename = errors.New("invalid migration filename")
func ParseMigrationVersion(filename string) (int, error) {
name := strings.TrimSuffix(filename, ".sql")
if name == "" || name == filename {
return 0, fmt.Errorf(
"%w: %q has no .sql extension or is empty",
ErrInvalidMigrationFilename, filename,
)
return 0, fmt.Errorf("%w: %q has no .sql extension or is empty", ErrInvalidMigrationFilename, filename)
}
// Split on underscore to separate version from description.
@@ -43,10 +40,7 @@ func ParseMigrationVersion(filename string) (int, error) {
versionStr, _, _ := strings.Cut(name, "_")
if versionStr == "" {
return 0, fmt.Errorf(
"%w: %q has empty version prefix",
ErrInvalidMigrationFilename, filename,
)
return 0, fmt.Errorf("%w: %q has empty version prefix", ErrInvalidMigrationFilename, filename)
}
// Validate the version is purely numeric.
@@ -183,12 +177,7 @@ func ApplyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error {
// applyMigrationTx reads and executes a migration file within a transaction,
// recording the version in schema_migrations on success.
func applyMigrationTx(
ctx context.Context,
db *sql.DB,
filename string,
version int,
) error {
func applyMigrationTx(ctx context.Context, db *sql.DB, filename string, version int) error {
content, err := migrationsFS.ReadFile("migrations/" + filename)
if err != nil {
return fmt.Errorf("failed to read migration %s: %w", filename, err)

View File

@@ -41,8 +41,7 @@ const stopTimeoutSeconds = 10
// gitImage is the Docker image used for git operations.
// alpine/git v2.47.2 - pulled 2025-12-30
const gitImage = "alpine/git@sha256:" +
"d86f367afb53d022acc4377741e7334bc20add161bb10234272b91b459b4b7d8"
const gitImage = "alpine/git@sha256:d86f367afb53d022acc4377741e7334bc20add161bb10234272b91b459b4b7d8"
// ErrNotConnected is returned when Docker client is not connected.
var ErrNotConnected = errors.New("docker client not connected")
@@ -146,7 +145,7 @@ type CreateContainerOptions struct {
Volumes []VolumeMount
Ports []PortMapping
Network string
CPULimit float64 // CPU cores (0.5 = half a core). 0 means unlimited.
CPULimit float64 // CPU cores (e.g. 0.5 = half a core, 2.0 = two cores). 0 means unlimited.
MemoryLimit int64 // Memory in bytes. 0 means unlimited.
}
@@ -304,11 +303,7 @@ func (c *Client) StopContainer(ctx context.Context, containerID ContainerID) err
timeout := stopTimeoutSeconds
err := c.docker.ContainerStop(
ctx,
containerID.String(),
container.StopOptions{Timeout: &timeout},
)
err := c.docker.ContainerStop(ctx, containerID.String(), container.StopOptions{Timeout: &timeout})
if err != nil {
return fmt.Errorf("failed to stop container: %w", err)
}
@@ -328,11 +323,7 @@ func (c *Client) RemoveContainer(
c.log.Info("removing container", "id", containerID, "force", force)
err := c.docker.ContainerRemove(
ctx,
containerID.String(),
container.RemoveOptions{Force: force},
)
err := c.docker.ContainerRemove(ctx, containerID.String(), container.RemoveOptions{Force: force})
if err != nil {
return fmt.Errorf("failed to remove container: %w", err)
}
@@ -478,8 +469,7 @@ type CloneResult struct {
CommitSHA string // The HEAD commit SHA after clone/checkout
}
// CloneRepo clones a git repository using SSH and optionally checks out a
// specific commit.
// CloneRepo clones a git repository using SSH and optionally checks out a specific commit.
// containerDir is the path inside the upaas container (for writing files).
// hostDir is the corresponding path on the Docker host (for bind mounts).
// If commitSHA is provided, that specific commit will be checked out.
@@ -594,13 +584,11 @@ func (c *Client) performBuild(
// scannerInitialBufferSize is the initial buffer size for the build log scanner.
const scannerInitialBufferSize = 64 * 1024 // 64KB
// scannerMaxBufferSize is the max buffer size for build log lines
// (base64 layers can be large).
// scannerMaxBufferSize is the max buffer size for build log lines (base64 layers can be large).
const scannerMaxBufferSize = 1024 * 1024 // 1MB
// streamBuildOutput reads Docker build output line by line and writes to
// stdout and optional log writer. Docker sends newline-delimited JSON, so
// reading line by line ensures each log entry is written immediately.
// streamBuildOutput reads Docker build output line by line and writes to stdout and optional log writer.
// Docker sends newline-delimited JSON, so reading line by line ensures each log entry is written immediately.
func (c *Client) streamBuildOutput(body io.Reader, logWriter io.Writer) error {
scanner := bufio.NewScanner(body)
buf := make([]byte, 0, scannerInitialBufferSize)
@@ -628,10 +616,7 @@ func (c *Client) streamBuildOutput(body io.Reader, logWriter io.Writer) error {
return nil
}
func (c *Client) performClone(
ctx context.Context,
cfg *cloneConfig,
) (*CloneResult, error) {
func (c *Client) performClone(ctx context.Context, cfg *cloneConfig) (*CloneResult, error) {
// Create work directory for clone destination
err := os.MkdirAll(cfg.containerDir, workDirPermissions)
if err != nil {
@@ -657,11 +642,7 @@ func (c *Client) performClone(
}
defer func() {
_ = c.docker.ContainerRemove(
ctx,
gitContainerID.String(),
container.RemoveOptions{Force: true},
)
_ = c.docker.ContainerRemove(ctx, gitContainerID.String(), container.RemoveOptions{Force: true})
}()
return c.runGitClone(ctx, gitContainerID)
@@ -699,8 +680,7 @@ func (c *Client) createGitContainer(
entrypoint := []string{}
cmd := []string{"sh", "-c", script}
// Use host paths for Docker bind mounts
// (Docker runs on the host, not in our container)
// Use host paths for Docker bind mounts (Docker runs on the host, not in our container)
resp, err := c.docker.ContainerCreate(ctx,
&container.Config{
Image: gitImage,
@@ -731,20 +711,13 @@ func (c *Client) createGitContainer(
return ContainerID(resp.ID), nil
}
func (c *Client) runGitClone(
ctx context.Context,
containerID ContainerID,
) (*CloneResult, error) {
func (c *Client) runGitClone(ctx context.Context, containerID ContainerID) (*CloneResult, error) {
err := c.docker.ContainerStart(ctx, containerID.String(), container.StartOptions{})
if err != nil {
return nil, fmt.Errorf("failed to start git container: %w", err)
}
statusCh, errCh := c.docker.ContainerWait(
ctx,
containerID.String(),
container.WaitConditionNotRunning,
)
statusCh, errCh := c.docker.ContainerWait(ctx, containerID.String(), container.WaitConditionNotRunning)
select {
case err := <-errCh:

View File

@@ -6,14 +6,11 @@ import (
"testing"
)
// mainBranch is the branch name used across validation tests.
const mainBranch = "main"
func TestValidBranchRegex(t *testing.T) {
t.Parallel()
valid := []string{
mainBranch,
"main",
"develop",
"feature/my-feature",
"release-1.0",
@@ -73,7 +70,7 @@ func TestValidCommitSHARegex(t *testing.T) {
}
}
func TestCloneRepoRejectsInjection(t *testing.T) {
func TestCloneRepoRejectsInjection(t *testing.T) { //nolint:funlen // table-driven test
t.Parallel()
c := &Client{
@@ -103,25 +100,25 @@ func TestCloneRepoRejectsInjection(t *testing.T) {
},
{
name: "injection in commitSHA",
branch: mainBranch,
branch: "main",
commitSHA: "not-a-sha; rm -rf /",
wantErr: ErrInvalidCommitSHA,
},
{
name: "short SHA rejected",
branch: mainBranch,
branch: "main",
commitSHA: "abc123",
wantErr: ErrInvalidCommitSHA,
},
{
name: "valid inputs pass validation (hit NotConnected)",
branch: mainBranch,
branch: "main",
commitSHA: "abc123def456789012345678901234567890abcd",
wantErr: ErrNotConnected,
},
{
name: "valid branch no SHA passes validation (hit NotConnected)",
branch: mainBranch,
branch: "main",
wantErr: ErrNotConnected,
},
}

View File

@@ -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{jsonKeyError: "invalid JSON body"},
map[string]string{"error": "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{jsonKeyError: "username and password are required"},
map[string]string{"error": "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{jsonKeyError: "invalid credentials"},
map[string]string{"error": "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{jsonKeyError: "failed to create session"},
map[string]string{"error": "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{jsonKeyError: "failed to list apps"},
map[string]string{"error": "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{jsonKeyError: "internal server error"},
map[string]string{"error": "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{jsonKeyError: "app not found"},
map[string]string{"error": "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{jsonKeyError: "app not found"},
map[string]string{"error": "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{jsonKeyError: "failed to list deployments"},
map[string]string{"error": "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{jsonKeyError: "unauthorized"},
map[string]string{"error": "unauthorized"},
http.StatusUnauthorized)
return

View File

@@ -7,7 +7,6 @@ import (
"errors"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
@@ -16,7 +15,6 @@ 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"
@@ -29,23 +27,6 @@ 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()
@@ -58,9 +39,7 @@ func (h *Handlers) HandleAppNew() http.HandlerFunc {
}
// HandleAppCreate handles app creation.
//
//nolint:funlen // validation adds necessary length
func (h *Handlers) HandleAppCreate() http.HandlerFunc {
func (h *Handlers) HandleAppCreate() http.HandlerFunc { //nolint:funlen // validation adds necessary length
tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, request *http.Request) {
@@ -181,14 +160,10 @@ 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{
dataKeyApp: application,
"App": application,
"EnvVars": envVars,
"Labels": labels,
"Volumes": volumes,
@@ -226,7 +201,7 @@ func (h *Handlers) HandleAppEdit() http.HandlerFunc {
}
data := h.addGlobals(map[string]any{
dataKeyApp: application,
"App": application,
}, request)
h.renderTemplate(writer, tmpl, "app_edit.html", data)
@@ -234,7 +209,7 @@ func (h *Handlers) HandleAppEdit() http.HandlerFunc {
}
// HandleAppUpdate handles app updates.
func (h *Handlers) HandleAppUpdate() http.HandlerFunc {
func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // validation adds necessary length
tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, request *http.Request) {
@@ -259,8 +234,8 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc {
nameErr := validateAppName(newName)
if nameErr != nil {
data := h.addGlobals(map[string]any{
dataKeyApp: application,
dataKeyError: "Invalid app name: " + nameErr.Error(),
"App": application,
"Error": "Invalid app name: " + nameErr.Error(),
}, request)
h.renderTemplate(writer, tmpl, "app_edit.html", data)
@@ -270,8 +245,8 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc {
repoURLErr := validateRepoURL(request.FormValue("repo_url"))
if repoURLErr != nil {
data := h.addGlobals(map[string]any{
dataKeyApp: application,
dataKeyError: "Invalid repository URL: " + repoURLErr.Error(),
"App": application,
"Error": "Invalid repository URL: " + repoURLErr.Error(),
}, request)
h.renderTemplate(writer, tmpl, "app_edit.html", data)
@@ -289,8 +264,8 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc {
limitsErr := applyResourceLimits(application, request)
if limitsErr != "" {
data := h.addGlobals(map[string]any{
dataKeyApp: application,
dataKeyError: limitsErr,
"App": application,
"Error": limitsErr,
}, request)
h.renderTemplate(writer, tmpl, "app_edit.html", data)
@@ -302,15 +277,16 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc {
h.log.Error("failed to update app", "error", saveErr)
data := h.addGlobals(map[string]any{
dataKeyApp: application,
dataKeyError: "Failed to update app",
"App": application,
"Error": "Failed to update app",
}, request)
h.renderTemplate(writer, tmpl, "app_edit.html", data)
return
}
redirectToApp(writer, request, application.ID, "?success=updated")
redirectURL := "/apps/" + application.ID + "?success=updated"
http.Redirect(writer, request, redirectURL, http.StatusSeeOther)
}
}
@@ -395,7 +371,12 @@ func (h *Handlers) HandleAppDeploy() http.HandlerFunc {
}
}(deployCtx, application)
redirectToApp(writer, request, application.ID, "/deployments")
http.Redirect(
writer,
request,
"/apps/"+application.ID+"/deployments",
http.StatusSeeOther,
)
}
}
@@ -416,7 +397,12 @@ func (h *Handlers) HandleCancelDeploy() http.HandlerFunc {
h.log.Info("deployment cancelled by user", "app", application.Name)
}
redirectToApp(writer, request, application.ID, "")
http.Redirect(
writer,
request,
"/apps/"+application.ID,
http.StatusSeeOther,
)
}
}
@@ -435,12 +421,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)
redirectToApp(writer, request, application.ID, "")
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
return
}
redirectToApp(writer, request, application.ID, "?success=rolledback")
http.Redirect(writer, request, "/apps/"+application.ID+"?success=rolledback", http.StatusSeeOther)
}
}
@@ -464,7 +450,7 @@ func (h *Handlers) HandleAppDeployments() http.HandlerFunc {
)
data := h.addGlobals(map[string]any{
dataKeyApp: application,
"App": application,
"Deployments": deployments,
}, request)
@@ -537,7 +523,7 @@ func (h *Handlers) HandleAppLogs() http.HandlerFunc {
return
}
_, _ = writer.Write([]byte(SanitizeLogs(logs))) // #nosec G705 -- output sanitized
_, _ = writer.Write([]byte(SanitizeLogs(logs))) // #nosec G705 -- logs sanitized, Content-Type is text/plain
}
}
@@ -576,8 +562,8 @@ func (h *Handlers) HandleDeploymentLogsAPI() http.HandlerFunc {
}
response := map[string]any{
jsonKeyLogs: logs,
jsonKeyStatus: deployment.Status,
"logs": logs,
"status": deployment.Status,
}
_ = json.NewEncoder(writer).Encode(response)
@@ -620,7 +606,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 -- internal path, not user input
_, err := os.Stat(logPath) // #nosec G703 -- path from internal GetLogFilePath, not user input
if os.IsNotExist(err) {
http.NotFound(writer, request)
@@ -640,7 +626,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) // #nosec G703 -- internal path
http.ServeFile(writer, request, logPath)
}
}
@@ -664,8 +650,8 @@ func (h *Handlers) HandleContainerLogsAPI() http.HandlerFunc {
containerInfo, containerErr := h.docker.FindContainerByAppID(request.Context(), appID)
if containerErr != nil || containerInfo == nil {
response := map[string]any{
jsonKeyLogs: "No container running\n",
jsonKeyStatus: "stopped",
"logs": "No container running\n",
"status": "stopped",
}
_ = json.NewEncoder(writer).Encode(response)
@@ -685,8 +671,8 @@ func (h *Handlers) HandleContainerLogsAPI() http.HandlerFunc {
)
response := map[string]any{
jsonKeyLogs: "Failed to fetch container logs\n",
jsonKeyStatus: "error",
"logs": "Failed to fetch container logs\n",
"status": "error",
}
_ = json.NewEncoder(writer).Encode(response)
@@ -699,8 +685,8 @@ func (h *Handlers) HandleContainerLogsAPI() http.HandlerFunc {
}
response := map[string]any{
jsonKeyLogs: SanitizeLogs(logs),
jsonKeyStatus: status,
"logs": SanitizeLogs(logs),
"status": status,
}
_ = json.NewEncoder(writer).Encode(response)
@@ -734,7 +720,7 @@ func (h *Handlers) HandleAppStatusAPI() http.HandlerFunc {
}
response := map[string]any{
jsonKeyStatus: string(application.Status),
"status": string(application.Status),
"latestDeploymentID": latestDeploymentID,
"latestDeploymentStatus": latestDeploymentStatus,
}
@@ -771,7 +757,7 @@ func (h *Handlers) HandleRecentDeploymentsAPI() http.HandlerFunc {
for _, d := range deployments {
deploymentsData = append(deploymentsData, map[string]any{
"id": d.ID,
jsonKeyStatus: string(d.Status),
"status": string(d.Status),
"duration": d.Duration(),
"shortCommit": d.ShortCommit(),
"finishedAtISO": d.FinishedAtISO(),
@@ -813,7 +799,7 @@ func (h *Handlers) handleContainerAction(
containerInfo, containerErr := h.docker.FindContainerByAppID(ctx, appID)
if containerErr != nil || containerInfo == nil {
redirectToApp(writer, request, appID, "")
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
return
}
@@ -846,7 +832,7 @@ func (h *Handlers) handleContainerAction(
"action", action, "app", application.Name, "container", containerID)
}
redirectToApp(writer, request, appID, "")
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
}
// HandleAppRestart handles restarting an app's container.
@@ -900,7 +886,7 @@ func (h *Handlers) addKeyValueToApp(
value := request.FormValue("value")
if key == "" || value == "" {
redirectToApp(writer, request, application.ID, "")
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
return
}
@@ -910,7 +896,7 @@ func (h *Handlers) addKeyValueToApp(
h.log.Error("failed to add key-value pair", "error", saveErr)
}
redirectToApp(writer, request, application.ID, "")
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
}
// envPairJSON represents a key-value pair in the JSON request body.
@@ -971,7 +957,7 @@ func (h *Handlers) HandleEnvVarSave() http.HandlerFunc {
decodeErr := json.NewDecoder(request.Body).Decode(&pairs)
if decodeErr != nil {
h.respondJSON(writer, request, map[string]string{
jsonKeyError: "invalid request body",
"error": "invalid request body",
}, http.StatusBadRequest)
return
@@ -980,7 +966,7 @@ func (h *Handlers) HandleEnvVarSave() http.HandlerFunc {
modelPairs, validationErr := validateEnvPairs(pairs)
if validationErr != "" {
h.respondJSON(writer, request, map[string]string{
jsonKeyError: validationErr,
"error": validationErr,
}, http.StatusBadRequest)
return
@@ -992,7 +978,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{
jsonKeyError: "failed to save environment variables",
"error": "failed to save environment variables",
}, http.StatusInternalServerError)
return
@@ -1020,77 +1006,32 @@ 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) {
h.deleteAppResource(
writer, request, "labelID", "label",
makeDeleteByID(h.db, models.FindLabel,
func(l *models.Label) string { return l.AppID },
(*models.Label).Delete,
),
)
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)
}
}
@@ -1118,7 +1059,12 @@ func (h *Handlers) HandleVolumeAdd() http.HandlerFunc {
readOnly := request.FormValue("readonly") == "1"
if hostPath == "" || containerPath == "" {
redirectToApp(writer, request, application.ID, "")
http.Redirect(
writer,
request,
"/apps/"+application.ID,
http.StatusSeeOther,
)
return
}
@@ -1126,7 +1072,7 @@ func (h *Handlers) HandleVolumeAdd() http.HandlerFunc {
pathErr := validateVolumePaths(hostPath, containerPath)
if pathErr != nil {
h.log.Error("invalid volume path", "error", pathErr)
redirectToApp(writer, request, application.ID, "")
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
return
}
@@ -1142,20 +1088,36 @@ func (h *Handlers) HandleVolumeAdd() http.HandlerFunc {
h.log.Error("failed to add volume", "error", saveErr)
}
redirectToApp(writer, request, application.ID, "")
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
}
}
// HandleVolumeDelete handles deleting a volume mount.
func (h *Handlers) HandleVolumeDelete() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
h.deleteAppResource(
writer, request, "volumeID", "volume",
makeDeleteByID(h.db, models.FindVolume,
func(v *models.Volume) string { return v.AppID },
(*models.Volume).Delete,
),
)
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)
}
}
@@ -1183,7 +1145,7 @@ func (h *Handlers) HandlePortAdd() http.HandlerFunc {
request.FormValue("container_port"),
)
if !valid {
redirectToApp(writer, request, application.ID, "")
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
return
}
@@ -1204,7 +1166,7 @@ func (h *Handlers) HandlePortAdd() http.HandlerFunc {
h.log.Error("failed to save port", "error", saveErr)
}
redirectToApp(writer, request, application.ID, "")
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
}
}
@@ -1228,13 +1190,29 @@ 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) {
h.deleteAppResource(
writer, request, "portID", "port",
makeDeleteByID(h.db, models.FindPort,
func(p *models.Port) string { return p.AppID },
(*models.Port).Delete,
),
)
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)
}
}
@@ -1296,7 +1274,7 @@ func (h *Handlers) HandleLabelEdit() http.HandlerFunc {
value := request.FormValue("value")
if key == "" || value == "" {
redirectToApp(writer, request, appID, "")
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
return
}
@@ -1309,7 +1287,7 @@ func (h *Handlers) HandleLabelEdit() http.HandlerFunc {
h.log.Error("failed to update label", "error", saveErr)
}
redirectToApp(writer, request, appID, "")
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
}
}
@@ -1345,7 +1323,7 @@ func (h *Handlers) HandleVolumeEdit() http.HandlerFunc {
readOnly := request.FormValue("readonly") == "1"
if hostPath == "" || containerPath == "" {
redirectToApp(writer, request, appID, "")
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
return
}
@@ -1353,7 +1331,7 @@ func (h *Handlers) HandleVolumeEdit() http.HandlerFunc {
pathErr := validateVolumePaths(hostPath, containerPath)
if pathErr != nil {
h.log.Error("invalid volume path", "error", pathErr)
redirectToApp(writer, request, appID, "")
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
return
}
@@ -1367,7 +1345,7 @@ func (h *Handlers) HandleVolumeEdit() http.HandlerFunc {
h.log.Error("failed to update volume", "error", saveErr)
}
redirectToApp(writer, request, appID, "")
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
}
}
@@ -1411,9 +1389,8 @@ 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 {
@@ -1448,8 +1425,7 @@ 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) {
@@ -1471,8 +1447,7 @@ 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)

View File

@@ -21,16 +21,8 @@ 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},

View File

@@ -22,19 +22,6 @@ 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

View File

@@ -32,11 +32,6 @@ import (
"sneak.berlin/go/upaas/internal/service/webhook"
)
const (
branchMain = "main"
paramSecret = "secret"
)
type testContext struct {
handlers *handlers.Handlers
database *database.Database
@@ -216,26 +211,6 @@ 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()
@@ -243,20 +218,31 @@ func TestHandleSetupGET(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
assertPageRenders(t, testCtx.handlers.HandleSetupGET(), "/setup", "setup")
request := httptest.NewRequestWithContext(t.Context(), 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")
})
}
func createSetupFormRequest(
t *testing.T,
username, password, confirm string,
) *http.Request {
t.Helper()
form := url.Values{}
form.Set("username", username)
form.Set("password", password)
form.Set("password_confirm", confirm)
request := httptest.NewRequestWithContext(
context.Background(),
t.Context(),
http.MethodPost,
"/setup",
strings.NewReader(form.Encode()),
@@ -271,7 +257,7 @@ func TestHandleSetupPOSTCreatesUserAndRedirects(t *testing.T) {
testCtx := setupTestHandlers(t)
request := createSetupFormRequest("admin", "password123", "password123")
request := createSetupFormRequest(t, "admin", "password123", "password123")
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleSetupPOST()
@@ -286,7 +272,7 @@ func TestHandleSetupPOSTRejectsEmptyUsername(t *testing.T) {
testCtx := setupTestHandlers(t)
request := createSetupFormRequest("", "password123", "password123")
request := createSetupFormRequest(t, "", "password123", "password123")
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleSetupPOST()
@@ -301,7 +287,7 @@ func TestHandleSetupPOSTRejectsShortPassword(t *testing.T) {
testCtx := setupTestHandlers(t)
request := createSetupFormRequest("admin", "short", "short")
request := createSetupFormRequest(t, "admin", "short", "short")
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleSetupPOST()
@@ -316,7 +302,7 @@ func TestHandleSetupPOSTRejectsMismatchedPasswords(t *testing.T) {
testCtx := setupTestHandlers(t)
request := createSetupFormRequest("admin", "password123", "different123")
request := createSetupFormRequest(t, "admin", "password123", "different123")
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleSetupPOST()
@@ -333,17 +319,27 @@ func TestHandleLoginGET(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
assertPageRenders(t, testCtx.handlers.HandleLoginGET(), "/login", "login")
request := httptest.NewRequestWithContext(t.Context(), 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")
})
}
func createLoginFormRequest(username, password string) *http.Request {
func createLoginFormRequest(t *testing.T, username, password string) *http.Request {
t.Helper()
form := url.Values{}
form.Set("username", username)
form.Set("password", password)
request := httptest.NewRequestWithContext(
context.Background(),
t.Context(),
http.MethodPost,
"/login",
strings.NewReader(form.Encode()),
@@ -366,7 +362,7 @@ func TestHandleLoginPOSTAuthenticatesValidCredentials(t *testing.T) {
)
require.NoError(t, createErr)
request := createLoginFormRequest("testuser", "testpass123")
request := createLoginFormRequest(t, "testuser", "testpass123")
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleLoginPOST()
@@ -389,7 +385,7 @@ func TestHandleLoginPOSTRejectsInvalidCredentials(t *testing.T) {
)
require.NoError(t, createErr)
request := createLoginFormRequest("testuser", "wrongpassword")
request := createLoginFormRequest(t, "testuser", "wrongpassword")
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleLoginPOST()
@@ -407,9 +403,7 @@ func TestHandleDashboard(t *testing.T) {
testCtx := setupTestHandlers(t)
request := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/", nil,
)
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleDashboard()
@@ -427,9 +421,7 @@ 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.NewRequestWithContext(
t.Context(), http.MethodGet, "/", nil,
)
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleDashboard()
@@ -490,7 +482,7 @@ func createTestApp(
app.CreateAppInput{
Name: name,
RepoURL: "git@example.com:user/" + name + ".git",
Branch: branchMain,
Branch: "main",
},
)
require.NoError(t, err)
@@ -511,7 +503,7 @@ func TestHandleWebhookRejectsOversizedBody(t *testing.T) {
app.CreateAppInput{
Name: "oversize-test-app",
RepoURL: "git@example.com:user/repo.git",
Branch: branchMain,
Branch: "main",
},
)
require.NoError(t, createErr)
@@ -527,7 +519,7 @@ func TestHandleWebhookRejectsOversizedBody(t *testing.T) {
)
request = addChiURLParams(
request,
map[string]string{paramSecret: createdApp.WebhookSecret},
map[string]string{"secret": createdApp.WebhookSecret},
)
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Gitea-Event", "push")
@@ -696,8 +688,7 @@ 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())
@@ -1047,8 +1038,7 @@ 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()
@@ -1064,21 +1054,13 @@ 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.NewRequestWithContext(
t.Context(), http.MethodGet, path, nil,
)
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, path, nil)
rr := httptest.NewRecorder()
wrapped.ServeHTTP(rr, req)
@@ -1091,9 +1073,7 @@ func TestSetupRequiredExemptsHealthAndStaticAndAPI(t *testing.T) {
t.Run("non-exempt redirects", func(t *testing.T) {
t.Parallel()
req := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/", nil,
)
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
rr := httptest.NewRecorder()
wrapped.ServeHTTP(rr, req)
@@ -1158,10 +1138,7 @@ func TestHandleWebhookReturns404ForUnknownSecret(t *testing.T) {
webhookURL,
strings.NewReader(payload),
)
request = addChiURLParams(
request,
map[string]string{paramSecret: "unknown-secret"},
)
request = addChiURLParams(request, map[string]string{"secret": "unknown-secret"})
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Gitea-Event", "push")
@@ -1184,7 +1161,7 @@ func TestHandleWebhookProcessesValidWebhook(t *testing.T) {
app.CreateAppInput{
Name: "webhook-test-app",
RepoURL: "git@example.com:user/repo.git",
Branch: branchMain,
Branch: "main",
},
)
require.NoError(t, createErr)
@@ -1199,7 +1176,7 @@ func TestHandleWebhookProcessesValidWebhook(t *testing.T) {
)
request = addChiURLParams(
request,
map[string]string{paramSecret: createdApp.WebhookSecret},
map[string]string{"secret": createdApp.WebhookSecret},
)
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Gitea-Event", "push")

View File

@@ -16,9 +16,7 @@ func TestRenderTemplateBuffersOutput(t *testing.T) {
testCtx := setupTestHandlers(t)
// The setup page is simple and has no DB dependencies
request := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/setup", nil,
)
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/setup", nil)
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleSetupGET()
@@ -61,9 +59,7 @@ func TestLoginRenderTemplateBuffersOutput(t *testing.T) {
testCtx := setupTestHandlers(t)
request := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/login", nil,
)
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/login", nil)
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleLoginGET()

View File

@@ -11,17 +11,13 @@ 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.
@@ -34,8 +30,7 @@ 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

View File

@@ -22,11 +22,7 @@ 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},
@@ -41,22 +37,10 @@ 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 {

View File

@@ -5,11 +5,8 @@ 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)

View File

@@ -6,7 +6,7 @@ import (
"sneak.berlin/go/upaas/internal/handlers"
)
func TestSanitizeLogs(t *testing.T) {
func TestSanitizeLogs(t *testing.T) { //nolint:funlen // table-driven tests
t.Parallel()
tests := []struct {

View File

@@ -55,8 +55,8 @@ func (h *Handlers) renderSetupError(
errorMsg string,
) {
data := h.addGlobals(map[string]any{
"Username": username,
dataKeyError: errorMsg,
"Username": username,
"Error": errorMsg,
}, request)
h.renderTemplate(writer, tmpl, "setup.html", data)
}

View File

@@ -47,8 +47,8 @@ func (h *Handlers) HandleAppWebhookEvents() http.HandlerFunc {
}
data := h.addGlobals(map[string]any{
dataKeyApp: application,
"Events": events,
"App": application,
"Events": events,
}, request)
h.renderTemplate(writer, tmpl, "webhook_events.html", data)

View File

@@ -24,30 +24,21 @@ func newCORSTestMiddleware(corsOrigins string) *Middleware {
}
}
// assertNoCORSHeaders runs a request with the given Origin header through
// CORS middleware configured with corsOrigins and asserts that no
// Access-Control-Allow-Origin header is set.
func assertNoCORSHeaders(t *testing.T, corsOrigins, origin, msg string) {
t.Helper()
func TestCORS_NoOriginsConfigured_NoCORSHeaders(t *testing.T) {
t.Parallel()
m := newCORSTestMiddleware(corsOrigins)
m := newCORSTestMiddleware("")
handler := m.CORS()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
req.Header.Set("Origin", origin)
req.Header.Set("Origin", "https://evil.com")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Empty(t, rec.Header().Get("Access-Control-Allow-Origin"), msg)
}
func TestCORS_NoOriginsConfigured_NoCORSHeaders(t *testing.T) {
t.Parallel()
assertNoCORSHeaders(t, "", "https://evil.com",
assert.Empty(t, rec.Header().Get("Access-Control-Allow-Origin"),
"expected no CORS headers when no origins configured")
}
@@ -74,6 +65,17 @@ func TestCORS_OriginsConfigured_AllowsMatchingOrigin(t *testing.T) {
func TestCORS_OriginsConfigured_RejectsNonMatchingOrigin(t *testing.T) {
t.Parallel()
assertNoCORSHeaders(t, "https://app.example.com", "https://evil.com",
m := newCORSTestMiddleware("https://app.example.com")
handler := m.CORS()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
req.Header.Set("Origin", "https://evil.com")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Empty(t, rec.Header().Get("Access-Control-Allow-Origin"),
"expected no CORS headers for non-matching origin")
}

View File

@@ -370,9 +370,8 @@ func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler {
}
}
// APISessionAuth returns middleware that requires session authentication
// for API routes. Unlike SessionAuth, it returns JSON 401 responses instead
// of redirecting to /login.
// APISessionAuth returns middleware that requires session authentication for API routes.
// Unlike SessionAuth, it returns JSON 401 responses instead of redirecting to /login.
func (m *Middleware) APISessionAuth() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(

View File

@@ -30,11 +30,9 @@ func TestLoginRateLimitAllowsUpToBurst(t *testing.T) {
mw := newTestMiddleware(t)
handler := mw.LoginRateLimit()(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
},
))
handler := mw.LoginRateLimit()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
// First 5 requests should succeed (burst)
for i := range 5 {
@@ -50,8 +48,7 @@ func TestLoginRateLimitAllowsUpToBurst(t *testing.T) {
req.RemoteAddr = "192.168.1.1:12345"
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusTooManyRequests, rec.Code,
"6th request should be rate limited")
assert.Equal(t, http.StatusTooManyRequests, rec.Code, "6th request should be rate limited")
}
//nolint:paralleltest // mutates global loginLimiter
@@ -60,23 +57,21 @@ func TestLoginRateLimitIsolatesIPs(t *testing.T) {
mw := newTestMiddleware(t)
handler := mw.LoginRateLimit()(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
},
))
handler := mw.LoginRateLimit()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
// Exhaust IP1's budget
for range 5 {
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/login", nil)
req.RemoteAddr = testProxyAddr
req.RemoteAddr = "10.0.0.1:1234"
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
}
// IP1 should be blocked
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/login", nil)
req.RemoteAddr = testProxyAddr
req.RemoteAddr = "10.0.0.1:1234"
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusTooManyRequests, rec.Code)
@@ -95,11 +90,9 @@ func TestLoginRateLimitReturns429Body(t *testing.T) {
mw := newTestMiddleware(t)
handler := mw.LoginRateLimit()(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
},
))
handler := mw.LoginRateLimit()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
// Exhaust burst
for range 5 {
@@ -115,8 +108,7 @@ func TestLoginRateLimitReturns429Body(t *testing.T) {
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusTooManyRequests, rec.Code)
assert.Contains(t, rec.Body.String(), "Too Many Requests")
assert.NotEmpty(t, rec.Header().Get("Retry-After"),
"should include Retry-After header")
assert.NotEmpty(t, rec.Header().Get("Retry-After"), "should include Retry-After header")
}
func TestIPLimiterEvictsStaleEntries(t *testing.T) {

View File

@@ -7,16 +7,6 @@ import (
"testing"
)
// Shared test addresses (also used by ratelimit_test.go).
const (
testProxyAddr = "10.0.0.1:1234"
testRealIP = "203.0.113.5"
testXFFIP = "198.51.100.1"
testPrivateIP = "192.168.1.1"
testPublicIP = "93.184.216.34"
testPublicDNSIP = "8.8.8.8"
)
func TestRealIP(t *testing.T) { //nolint:funlen // table-driven test
t.Parallel()
@@ -30,63 +20,63 @@ func TestRealIP(t *testing.T) { //nolint:funlen // table-driven test
// === Trusted proxy (RFC1918 / loopback) — headers ARE honoured ===
{
name: "trusted: X-Real-IP from 10.x",
remoteAddr: testProxyAddr,
xRealIP: testRealIP,
remoteAddr: "10.0.0.1:1234",
xRealIP: "203.0.113.5",
xff: "198.51.100.1, 10.0.0.1",
want: testRealIP,
want: "203.0.113.5",
},
{
name: "trusted: XFF from 10.x when no X-Real-IP",
remoteAddr: testProxyAddr,
remoteAddr: "10.0.0.1:1234",
xff: "198.51.100.1, 10.0.0.1",
want: testXFFIP,
want: "198.51.100.1",
},
{
name: "trusted: XFF single IP from 10.x",
remoteAddr: testProxyAddr,
remoteAddr: "10.0.0.1:1234",
xff: "203.0.113.10",
want: "203.0.113.10",
},
{
name: "trusted: falls back to RemoteAddr (192.168.x)",
remoteAddr: "192.168.1.1:5678",
want: testPrivateIP,
want: "192.168.1.1",
},
{
name: "trusted: RemoteAddr without port",
remoteAddr: testPrivateIP,
want: testPrivateIP,
remoteAddr: "192.168.1.1",
want: "192.168.1.1",
},
{
name: "trusted: X-Real-IP with whitespace from 10.x",
remoteAddr: testProxyAddr,
remoteAddr: "10.0.0.1:1234",
xRealIP: " 203.0.113.5 ",
want: testRealIP,
want: "203.0.113.5",
},
{
name: "trusted: XFF with whitespace from 10.x",
remoteAddr: testProxyAddr,
remoteAddr: "10.0.0.1:1234",
xff: " 198.51.100.1 , 10.0.0.1",
want: testXFFIP,
want: "198.51.100.1",
},
{
name: "trusted: empty X-Real-IP falls through to XFF from 10.x",
remoteAddr: testProxyAddr,
remoteAddr: "10.0.0.1:1234",
xRealIP: " ",
xff: testXFFIP,
want: testXFFIP,
xff: "198.51.100.1",
want: "198.51.100.1",
},
{
name: "trusted: loopback honours X-Real-IP",
remoteAddr: "127.0.0.1:9999",
xRealIP: testPublicIP,
want: testPublicIP,
xRealIP: "93.184.216.34",
want: "93.184.216.34",
},
{
name: "trusted: 172.16.x honours XFF",
remoteAddr: "172.16.0.1:4321",
xff: testPublicDNSIP,
want: testPublicDNSIP,
xff: "8.8.8.8",
want: "8.8.8.8",
},
// === Untrusted proxy (public IP) — headers IGNORED, use RemoteAddr ===
@@ -107,17 +97,17 @@ func TestRealIP(t *testing.T) { //nolint:funlen // table-driven test
remoteAddr: "8.8.8.8:443",
xRealIP: "1.2.3.4",
xff: "5.6.7.8",
want: testPublicDNSIP,
want: "8.8.8.8",
},
{
name: "untrusted: no headers, public RemoteAddr",
remoteAddr: "93.184.216.34:8080",
want: testPublicIP,
want: "93.184.216.34",
},
{
name: "untrusted: public RemoteAddr without port",
remoteAddr: testPublicIP,
want: testPublicIP,
remoteAddr: "93.184.216.34",
want: "93.184.216.34",
},
}
@@ -149,9 +139,7 @@ func TestIsTrustedProxy(t *testing.T) {
trusted := []string{"10.0.0.1", "10.255.255.255", "172.16.0.1", "172.31.255.255",
"192.168.0.1", "192.168.255.255", "127.0.0.1", "127.255.255.255", "::1"}
untrusted := []string{
testPublicDNSIP, "203.0.113.1", "172.32.0.1", "11.0.0.1", "2001:db8::1",
}
untrusted := []string{"8.8.8.8", "203.0.113.1", "172.32.0.1", "11.0.0.1", "2001:db8::1"}
for _, addr := range trusted {
ip := net.ParseIP(addr)

View File

@@ -93,41 +93,6 @@ func FindEnvVar(
return envVar, nil
}
// findAllByAppID loads all rows for an app, scanning each row into a
// new model created by newFn. entity names the model in error messages.
func findAllByAppID[T interface{ scanDest() []any }](
ctx context.Context,
db *database.Database,
query, appID, entity string,
newFn func(*database.Database) T,
) ([]T, error) {
rows, err := db.Query(ctx, query, appID)
if err != nil {
return nil, fmt.Errorf("querying %s by app: %w", entity, err)
}
defer func() { _ = rows.Close() }()
var items []T
for rows.Next() {
item := newFn(db)
scanErr := rows.Scan(item.scanDest()...)
if scanErr != nil {
return nil, scanErr
}
items = append(items, item)
}
return items, rows.Err()
}
func (e *EnvVar) scanDest() []any {
return []any{&e.ID, &e.AppID, &e.Key, &e.Value}
}
// FindEnvVarsByAppID finds all env vars for an app.
func FindEnvVarsByAppID(
ctx context.Context,
@@ -138,7 +103,29 @@ func FindEnvVarsByAppID(
SELECT id, app_id, key, value FROM app_env_vars
WHERE app_id = ? ORDER BY key`
return findAllByAppID(ctx, db, query, appID, "env vars", NewEnvVar)
rows, err := db.Query(ctx, query, appID)
if err != nil {
return nil, fmt.Errorf("querying env vars by app: %w", err)
}
defer func() { _ = rows.Close() }()
var envVars []*EnvVar
for rows.Next() {
envVar := NewEnvVar(db)
scanErr := rows.Scan(
&envVar.ID, &envVar.AppID, &envVar.Key, &envVar.Value,
)
if scanErr != nil {
return nil, scanErr
}
envVars = append(envVars, envVar)
}
return envVars, rows.Err()
}
// EnvVarPair is a key-value pair for bulk env var operations.

View File

@@ -93,10 +93,6 @@ func FindLabel(
return label, nil
}
func (l *Label) scanDest() []any {
return []any{&l.ID, &l.AppID, &l.Key, &l.Value}
}
// FindLabelsByAppID finds all labels for an app.
func FindLabelsByAppID(
ctx context.Context,
@@ -107,7 +103,27 @@ func FindLabelsByAppID(
SELECT id, app_id, key, value FROM app_labels
WHERE app_id = ? ORDER BY key`
return findAllByAppID(ctx, db, query, appID, "labels", NewLabel)
rows, err := db.Query(ctx, query, appID)
if err != nil {
return nil, fmt.Errorf("querying labels by app: %w", err)
}
defer func() { _ = rows.Close() }()
var labels []*Label
for rows.Next() {
label := NewLabel(db)
scanErr := rows.Scan(&label.ID, &label.AppID, &label.Key, &label.Value)
if scanErr != nil {
return nil, scanErr
}
labels = append(labels, label)
}
return labels, rows.Err()
}
// DeleteLabelsByAppID deletes all labels for an app.

View File

@@ -317,54 +317,33 @@ func TestAllApps(t *testing.T) {
// EnvVar Tests.
// testKVCreateAndFind exercises the create-and-find round trip shared
// by key-value models (env vars, labels).
func testKVCreateAndFind[T any](
t *testing.T,
wantKey string,
create func(db *database.Database, appID string) (int64, error),
find func(context.Context, *database.Database, string) ([]T, error),
keyOf func(T) string,
) {
t.Helper()
testDB, cleanup := setupTestDB(t)
defer cleanup()
// Create app first.
app := createTestApp(t, testDB)
id, err := create(testDB, app.ID)
require.NoError(t, err)
assert.NotZero(t, id)
found, err := find(context.Background(), testDB, app.ID)
require.NoError(t, err)
require.Len(t, found, 1)
assert.Equal(t, wantKey, keyOf(found[0]))
}
func saveTestEnvVar(db *database.Database, appID string) (int64, error) {
envVar := models.NewEnvVar(db)
envVar.AppID = appID
envVar.Key = "DATABASE_URL"
envVar.Value = "postgres://localhost/db"
err := envVar.Save(context.Background())
return envVar.ID, err
}
func TestEnvVarCRUD(t *testing.T) {
t.Parallel()
t.Run("creates and finds env vars", func(t *testing.T) {
t.Parallel()
testKVCreateAndFind(t, "DATABASE_URL", saveTestEnvVar,
models.FindEnvVarsByAppID,
func(e *models.EnvVar) string { return e.Key },
testDB, cleanup := setupTestDB(t)
defer cleanup()
// Create app first.
app := createTestApp(t, testDB)
envVar := models.NewEnvVar(testDB)
envVar.AppID = app.ID
envVar.Key = "DATABASE_URL"
envVar.Value = "postgres://localhost/db"
err := envVar.Save(context.Background())
require.NoError(t, err)
assert.NotZero(t, envVar.ID)
envVars, err := models.FindEnvVarsByAppID(
context.Background(), testDB, app.ID,
)
require.NoError(t, err)
require.Len(t, envVars, 1)
assert.Equal(t, "DATABASE_URL", envVars[0].Key)
})
t.Run("deletes env var", func(t *testing.T) {
@@ -396,27 +375,32 @@ func TestEnvVarCRUD(t *testing.T) {
// Label Tests.
func saveTestLabel(db *database.Database, appID string) (int64, error) {
label := models.NewLabel(db)
label.AppID = appID
label.Key = "traefik.enable"
label.Value = "true"
err := label.Save(context.Background())
return label.ID, err
}
func TestLabelCRUD(t *testing.T) {
t.Parallel()
t.Run("creates and finds labels", func(t *testing.T) {
t.Parallel()
testKVCreateAndFind(t, "traefik.enable", saveTestLabel,
models.FindLabelsByAppID,
func(l *models.Label) string { return l.Key },
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
label := models.NewLabel(testDB)
label.AppID = app.ID
label.Key = "traefik.enable"
label.Value = "true"
err := label.Save(context.Background())
require.NoError(t, err)
assert.NotZero(t, label.ID)
labels, err := models.FindLabelsByAppID(
context.Background(), testDB, app.ID,
)
require.NoError(t, err)
require.Len(t, labels, 1)
assert.Equal(t, "traefik.enable", labels[0].Key)
})
}
@@ -585,9 +569,7 @@ func TestDeploymentFindByAppID(t *testing.T) {
require.NoError(t, err)
}
deployments, err := models.FindDeploymentsByAppID(
context.Background(), testDB, app.ID, 3,
)
deployments, err := models.FindDeploymentsByAppID(context.Background(), testDB, app.ID, 3)
require.NoError(t, err)
assert.Len(t, deployments, 3)
}
@@ -724,6 +706,7 @@ func TestAppGetWebhookEvents(t *testing.T) {
// Cascade Delete Tests.
//nolint:funlen // Test function with many assertions - acceptable for integration tests
func TestCascadeDelete(t *testing.T) {
t.Parallel()
@@ -800,8 +783,7 @@ func TestCascadeDelete(t *testing.T) {
// Resource Limits Tests.
//nolint:funlen // integration test with multiple subtests
func TestAppResourceLimits(t *testing.T) {
func TestAppResourceLimits(t *testing.T) { //nolint:funlen // integration test with multiple subtests
t.Parallel()
t.Run("saves and loads CPU limit", func(t *testing.T) {

View File

@@ -112,12 +112,6 @@ func FindPort(
return port, nil
}
func (p *Port) scanDest() []any {
return []any{
&p.ID, &p.AppID, &p.HostPort, &p.ContainerPort, &p.Protocol,
}
}
// FindPortsByAppID finds all ports for an app.
func FindPortsByAppID(
ctx context.Context,
@@ -128,7 +122,30 @@ func FindPortsByAppID(
SELECT id, app_id, host_port, container_port, protocol
FROM app_ports WHERE app_id = ? ORDER BY host_port`
return findAllByAppID(ctx, db, query, appID, "ports", NewPort)
rows, err := db.Query(ctx, query, appID)
if err != nil {
return nil, fmt.Errorf("querying ports by app: %w", err)
}
defer func() { _ = rows.Close() }()
var ports []*Port
for rows.Next() {
port := NewPort(db)
scanErr := rows.Scan(
&port.ID, &port.AppID, &port.HostPort,
&port.ContainerPort, &port.Protocol,
)
if scanErr != nil {
return nil, scanErr
}
ports = append(ports, port)
}
return ports, rows.Err()
}
// DeletePortsByAppID deletes all ports for an app.

View File

@@ -103,12 +103,6 @@ func FindVolume(
return vol, nil
}
func (v *Volume) scanDest() []any {
return []any{
&v.ID, &v.AppID, &v.HostPath, &v.ContainerPath, &v.ReadOnly,
}
}
// FindVolumesByAppID finds all volumes for an app.
func FindVolumesByAppID(
ctx context.Context,
@@ -119,7 +113,30 @@ func FindVolumesByAppID(
SELECT id, app_id, host_path, container_path, readonly
FROM app_volumes WHERE app_id = ? ORDER BY container_path`
return findAllByAppID(ctx, db, query, appID, "volumes", NewVolume)
rows, err := db.Query(ctx, query, appID)
if err != nil {
return nil, fmt.Errorf("querying volumes by app: %w", err)
}
defer func() { _ = rows.Close() }()
var volumes []*Volume
for rows.Next() {
vol := NewVolume(db)
scanErr := rows.Scan(
&vol.ID, &vol.AppID, &vol.HostPath,
&vol.ContainerPath, &vol.ReadOnly,
)
if scanErr != nil {
return nil, scanErr
}
volumes = append(volumes, vol)
}
return volumes, rows.Err()
}
// DeleteVolumesByAppID deletes all volumes for an app.

View File

@@ -71,14 +71,8 @@ func (s *Server) SetupRoutes() {
r.Post("/apps/{id}/deployments/cancel", s.handlers.HandleCancelDeploy())
r.Get("/apps/{id}/deployments", s.handlers.HandleAppDeployments())
r.Get("/apps/{id}/webhooks", s.handlers.HandleAppWebhookEvents())
r.Get(
"/apps/{id}/deployments/{deploymentID}/logs",
s.handlers.HandleDeploymentLogsAPI(),
)
r.Get(
"/apps/{id}/deployments/{deploymentID}/download",
s.handlers.HandleDeploymentLogDownload(),
)
r.Get("/apps/{id}/deployments/{deploymentID}/logs", s.handlers.HandleDeploymentLogsAPI())
r.Get("/apps/{id}/deployments/{deploymentID}/download", s.handlers.HandleDeploymentLogDownload())
r.Get("/apps/{id}/logs", s.handlers.HandleAppLogs())
r.Get("/apps/{id}/container-logs", s.handlers.HandleContainerLogsAPI())
r.Get("/apps/{id}/status", s.handlers.HandleAppStatusAPI())

View File

@@ -16,12 +16,6 @@ import (
"sneak.berlin/go/upaas/internal/service/app"
)
// testRepoURL is the default repository URL used across tests.
const testRepoURL = "git@example.com:user/repo.git"
// giteaRepoURL is the gitea repository URL used across tests.
const giteaRepoURL = "git@gitea.example.com:user/repo.git"
func setupTestService(t *testing.T) (*app.Service, func()) {
t.Helper()
@@ -64,8 +58,7 @@ func setupTestService(t *testing.T) (*app.Service, func()) {
}
// deleteItemTestHelper is a generic helper for testing delete operations.
// It creates an app, adds an item, verifies it exists, deletes it, and
// verifies it's gone.
// It creates an app, adds an item, verifies it exists, deletes it, and verifies it's gone.
func deleteItemTestHelper(
t *testing.T,
appName string,
@@ -80,7 +73,7 @@ func deleteItemTestHelper(
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: appName,
RepoURL: testRepoURL,
RepoURL: "git@example.com:user/repo.git",
})
require.NoError(t, err)
@@ -99,35 +92,6 @@ func deleteItemTestHelper(
assert.Equal(t, 0, count)
}
// runDeleteItemTest adapts typed list/delete callbacks so delete tests for
// different item types can share deleteItemTestHelper.
func runDeleteItemTest[T any](
t *testing.T,
appName string,
addItem func(ctx context.Context, svc *app.Service, appID string) error,
listItems func(ctx context.Context, application *models.App) ([]T, error),
deleteFirst func(ctx context.Context, svc *app.Service, item T) error,
) {
t.Helper()
deleteItemTestHelper(t, appName,
addItem,
func(ctx context.Context, application *models.App) (int, error) {
items, err := listItems(ctx, application)
return len(items), err
},
func(ctx context.Context, svc *app.Service, application *models.App) error {
items, err := listItems(ctx, application)
if err != nil {
return err
}
return deleteFirst(ctx, svc, items[0])
},
)
}
func TestCreateAppWithGeneratedKeys(t *testing.T) {
t.Parallel()
@@ -136,7 +100,7 @@ func TestCreateAppWithGeneratedKeys(t *testing.T) {
input := app.CreateAppInput{
Name: "test-app",
RepoURL: giteaRepoURL,
RepoURL: "git@gitea.example.com:user/repo.git",
Branch: "main",
DockerfilePath: "Dockerfile",
}
@@ -146,7 +110,7 @@ func TestCreateAppWithGeneratedKeys(t *testing.T) {
require.NotNil(t, createdApp)
assert.Equal(t, "test-app", createdApp.Name)
assert.Equal(t, giteaRepoURL, createdApp.RepoURL)
assert.Equal(t, "git@gitea.example.com:user/repo.git", createdApp.RepoURL)
assert.Equal(t, "main", createdApp.Branch)
assert.Equal(t, "Dockerfile", createdApp.DockerfilePath)
assert.NotEmpty(t, createdApp.ID)
@@ -166,7 +130,7 @@ func TestCreateAppDefaults(t *testing.T) {
input := app.CreateAppInput{
Name: "test-app-defaults",
RepoURL: giteaRepoURL,
RepoURL: "git@gitea.example.com:user/repo.git",
}
createdApp, err := svc.CreateApp(context.Background(), input)
@@ -184,7 +148,7 @@ func TestCreateAppOptionalFields(t *testing.T) {
input := app.CreateAppInput{
Name: "test-app-full",
RepoURL: giteaRepoURL,
RepoURL: "git@gitea.example.com:user/repo.git",
Branch: "develop",
DockerNetwork: "my-network",
NtfyTopic: "https://ntfy.sh/my-topic",
@@ -212,7 +176,7 @@ func TestUpdateApp(testingT *testing.T) {
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "original-name",
RepoURL: testRepoURL,
RepoURL: "git@example.com:user/repo.git",
})
require.NoError(t, err)
@@ -244,7 +208,7 @@ func TestUpdateApp(testingT *testing.T) {
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "test-clear",
RepoURL: testRepoURL,
RepoURL: "git@example.com:user/repo.git",
NtfyTopic: "https://ntfy.sh/topic",
SlackWebhook: "https://slack.com/hook",
})
@@ -252,7 +216,7 @@ func TestUpdateApp(testingT *testing.T) {
err = svc.UpdateApp(context.Background(), createdApp, app.UpdateAppInput{
Name: "test-clear",
RepoURL: testRepoURL,
RepoURL: "git@example.com:user/repo.git",
Branch: "main",
})
require.NoError(t, err)
@@ -276,7 +240,7 @@ func TestDeleteApp(testingT *testing.T) {
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "to-delete",
RepoURL: testRepoURL,
RepoURL: "git@example.com:user/repo.git",
})
require.NoError(t, err)
@@ -300,7 +264,7 @@ func TestGetApp(testingT *testing.T) {
created, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "findable-app",
RepoURL: testRepoURL,
RepoURL: "git@example.com:user/repo.git",
})
require.NoError(t, err)
@@ -335,7 +299,7 @@ func TestGetAppByWebhookSecret(testingT *testing.T) {
created, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "webhook-app",
RepoURL: testRepoURL,
RepoURL: "git@example.com:user/repo.git",
})
require.NoError(t, err)
@@ -414,7 +378,7 @@ func TestEnvVarsAddAndRetrieve(t *testing.T) {
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "env-test",
RepoURL: testRepoURL,
RepoURL: "git@example.com:user/repo.git",
})
require.NoError(t, err)
@@ -447,33 +411,29 @@ func TestEnvVarsAddAndRetrieve(t *testing.T) {
assert.Equal(t, "secret123", keys["API_KEY"])
}
// addDeletableEnvVar seeds the env var removed in the delete test.
func addDeletableEnvVar(
ctx context.Context, svc *app.Service, appID string,
) error {
return svc.AddEnvVar(ctx, appID, "TO_DELETE", "value")
}
func TestEnvVarsDelete(t *testing.T) {
t.Parallel()
runDeleteItemTest(t, "env-delete-test", addDeletableEnvVar,
func(ctx context.Context, application *models.App) ([]*models.EnvVar, error) {
return application.GetEnvVars(ctx)
deleteItemTestHelper(t, "env-delete-test",
func(ctx context.Context, svc *app.Service, appID string) error {
return svc.AddEnvVar(ctx, appID, "TO_DELETE", "value")
},
func(ctx context.Context, svc *app.Service, item *models.EnvVar) error {
return svc.DeleteEnvVar(ctx, item.ID)
func(ctx context.Context, application *models.App) (int, error) {
envVars, err := application.GetEnvVars(ctx)
return len(envVars), err
},
func(ctx context.Context, svc *app.Service, application *models.App) error {
envVars, err := application.GetEnvVars(ctx)
if err != nil {
return err
}
return svc.DeleteEnvVar(ctx, envVars[0].ID)
},
)
}
// addDeletableLabel seeds the label removed in the delete test.
func addDeletableLabel(
ctx context.Context, svc *app.Service, appID string,
) error {
return svc.AddLabel(ctx, appID, "to.delete", "value")
}
func TestLabels(testingT *testing.T) {
testingT.Parallel()
@@ -485,7 +445,7 @@ func TestLabels(testingT *testing.T) {
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "label-test",
RepoURL: testRepoURL,
RepoURL: "git@example.com:user/repo.git",
})
require.NoError(t, err)
@@ -508,12 +468,22 @@ func TestLabels(testingT *testing.T) {
testingT.Run("deletes label", func(t *testing.T) {
t.Parallel()
runDeleteItemTest(t, "label-delete-test", addDeletableLabel,
func(ctx context.Context, application *models.App) ([]*models.Label, error) {
return application.GetLabels(ctx)
deleteItemTestHelper(t, "label-delete-test",
func(ctx context.Context, svc *app.Service, appID string) error {
return svc.AddLabel(ctx, appID, "to.delete", "value")
},
func(ctx context.Context, svc *app.Service, item *models.Label) error {
return svc.DeleteLabel(ctx, item.ID)
func(ctx context.Context, application *models.App) (int, error) {
labels, err := application.GetLabels(ctx)
return len(labels), err
},
func(ctx context.Context, svc *app.Service, application *models.App) error {
labels, err := application.GetLabels(ctx)
if err != nil {
return err
}
return svc.DeleteLabel(ctx, labels[0].ID)
},
)
})
@@ -527,7 +497,7 @@ func TestVolumesAddAndRetrieve(t *testing.T) {
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "volume-test",
RepoURL: testRepoURL,
RepoURL: "git@example.com:user/repo.git",
})
require.NoError(t, err)
@@ -577,7 +547,7 @@ func TestVolumesDelete(t *testing.T) {
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "volume-delete-test",
RepoURL: testRepoURL,
RepoURL: "git@example.com:user/repo.git",
})
require.NoError(t, err)
@@ -613,7 +583,7 @@ func TestUpdateAppStatus(testingT *testing.T) {
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "status-test",
RepoURL: testRepoURL,
RepoURL: "git@example.com:user/repo.git",
})
require.NoError(t, err)
assert.Equal(t, models.AppStatusPending, createdApp.Status)

View File

@@ -144,11 +144,7 @@ func TestSessionCookieSecureFlag(testingT *testing.T) {
svc := setupAuthService(t, false)
cookie := getSessionCookie(t, svc)
require.NotNil(t, cookie, "session cookie should exist")
assert.True(
t,
cookie.Secure,
"session cookie should have Secure flag in production mode",
)
assert.True(t, cookie.Secure, "session cookie should have Secure flag in production mode")
})
}
@@ -328,12 +324,7 @@ func TestCreateUserRaceCondition(testingT *testing.T) {
}
assert.Equal(t, 1, successes, "exactly one goroutine should succeed")
assert.Equal(
t,
goroutines-1,
failures,
"all other goroutines should fail with ErrUserExists",
)
assert.Equal(t, goroutines-1, failures, "all other goroutines should fail with ErrUserExists")
})
}
@@ -389,9 +380,7 @@ func TestDestroySessionMaxAge(testingT *testing.T) {
defer cleanup()
recorder := httptest.NewRecorder()
request := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/", nil,
)
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
err := svc.DestroySession(recorder, request)
require.NoError(t, err)

View File

@@ -66,8 +66,7 @@ const logFilePermissions = 0o640
// logTimestampFormat is the format for log file timestamps.
const logTimestampFormat = "20060102T150405Z"
// logFileShortSHALength is the number of characters to use for commit SHA
// in log filenames.
// logFileShortSHALength is the number of characters to use for commit SHA in log filenames.
const logFileShortSHALength = 12
// dockerLogMessage represents a Docker build log message.
@@ -88,10 +87,7 @@ type deploymentLogWriter struct {
flushCtx context.Context //nolint:containedctx // needed for async flush goroutine
}
func newDeploymentLogWriter(
ctx context.Context,
deployment *models.Deployment,
) *deploymentLogWriter {
func newDeploymentLogWriter(ctx context.Context, deployment *models.Deployment) *deploymentLogWriter {
w := &deploymentLogWriter{
deployment: deployment,
done: make(chan struct{}),
@@ -261,10 +257,7 @@ func (svc *Service) GetBuildDir(appName string) string {
// GetLogFilePath returns the path to the log file for a deployment.
// Returns empty string if the path cannot be determined.
func (svc *Service) GetLogFilePath(
app *models.App,
deployment *models.Deployment,
) string {
func (svc *Service) GetLogFilePath(app *models.App, deployment *models.Deployment) string {
hostname, err := os.Hostname()
if err != nil {
hostname = "unknown"
@@ -282,8 +275,7 @@ func (svc *Service) GetLogFilePath(
// Use started_at timestamp
timestamp := deployment.StartedAt.UTC().Format(logTimestampFormat)
// Build filename: appname_sha_timestamp.log.txt
// (or appname_timestamp.log.txt if no SHA)
// Build filename: appname_sha_timestamp.log.txt (or appname_timestamp.log.txt if no SHA)
var filename string
if sha != "" {
filename = fmt.Sprintf("%s_%s_%s.log.txt", app.Name, sha, timestamp)
@@ -316,8 +308,7 @@ func (svc *Service) CancelDeploy(appID string) bool {
// Deploy deploys an app. If cancelExisting is true (e.g. webhook-triggered),
// any in-progress deploy for the same app will be cancelled before starting.
// If cancelExisting is false and a deploy is in progress,
// ErrDeploymentInProgress is returned.
// If cancelExisting is false and a deploy is in progress, ErrDeploymentInProgress is returned.
func (svc *Service) Deploy(
ctx context.Context,
app *models.App,
@@ -351,8 +342,7 @@ func (svc *Service) Deploy(
// Fetch webhook event and create deployment record
webhookEvent := svc.fetchWebhookEvent(deployCtx, webhookEventID)
// Use a background context for DB operations that must complete
// regardless of cancellation
// Use a background context for DB operations that must complete regardless of cancellation
bgCtx := context.WithoutCancel(deployCtx)
deployment, err := svc.createDeploymentRecord(bgCtx, app, webhookEventID, webhookEvent)
@@ -411,10 +401,7 @@ func (svc *Service) createRollbackDeployment(
return nil, fmt.Errorf("failed to create rollback deployment: %w", saveErr)
}
_ = deployment.AppendLog(
ctx,
"Rolling back to previous image: "+app.PreviousImageID.String,
)
_ = deployment.AppendLog(ctx, "Rolling back to previous image: "+app.PreviousImageID.String)
return deployment, nil
}
@@ -430,11 +417,7 @@ func (svc *Service) executeRollback(
svc.removeOldContainer(ctx, app, deployment)
rollbackOpts, err := svc.buildContainerOptions(
ctx,
app,
docker.ImageID(previousImageID),
)
rollbackOpts, err := svc.buildContainerOptions(ctx, app, docker.ImageID(previousImageID))
if err != nil {
svc.failDeployment(bgCtx, app, deployment, err)
@@ -443,12 +426,7 @@ func (svc *Service) executeRollback(
containerID, err := svc.docker.CreateContainer(ctx, rollbackOpts)
if err != nil {
svc.failDeployment(
bgCtx,
app,
deployment,
fmt.Errorf("failed to create rollback container: %w", err),
)
svc.failDeployment(bgCtx, app, deployment, fmt.Errorf("failed to create rollback container: %w", err))
return fmt.Errorf("failed to create rollback container: %w", err)
}
@@ -458,12 +436,7 @@ func (svc *Service) executeRollback(
startErr := svc.docker.StartContainer(ctx, containerID)
if startErr != nil {
svc.failDeployment(
bgCtx,
app,
deployment,
fmt.Errorf("failed to start rollback container: %w", startErr),
)
svc.failDeployment(bgCtx, app, deployment, fmt.Errorf("failed to start rollback container: %w", startErr))
return fmt.Errorf("failed to start rollback container: %w", startErr)
}
@@ -722,11 +695,7 @@ func (svc *Service) cleanupCancelledDeploy(
if removeErr != nil {
svc.log.Error("failed to remove image from cancelled deploy",
"error", removeErr, "app", app.Name, "image", imageID)
_ = deployment.AppendLog(
ctx,
"WARNING: failed to clean up image "+
imageID.String()+": "+removeErr.Error(),
)
_ = deployment.AppendLog(ctx, "WARNING: failed to clean up image "+imageID.String()+": "+removeErr.Error())
} else {
svc.log.Info("cleaned up image from cancelled deploy",
"app", app.Name, "image", imageID)
@@ -901,24 +870,14 @@ func (svc *Service) cloneRepository(
err := os.MkdirAll(appBuildsDir, buildsDirPermissions)
if err != nil {
svc.failDeployment(
ctx,
app,
deployment,
fmt.Errorf("failed to create builds dir: %w", err),
)
svc.failDeployment(ctx, app, deployment, fmt.Errorf("failed to create builds dir: %w", err))
return "", nil, fmt.Errorf("failed to create builds dir: %w", err)
}
buildDir, err := os.MkdirTemp(appBuildsDir, fmt.Sprintf("%d-*", deployment.ID))
if err != nil {
svc.failDeployment(
ctx,
app,
deployment,
fmt.Errorf("failed to create temp dir: %w", err),
)
svc.failDeployment(ctx, app, deployment, fmt.Errorf("failed to create temp dir: %w", err))
return "", nil, fmt.Errorf("failed to create temp dir: %w", err)
}
@@ -949,12 +908,7 @@ func (svc *Service) cloneRepository(
)
if cloneErr != nil {
cleanup()
svc.failDeployment(
ctx,
app,
deployment,
fmt.Errorf("failed to clone repo: %w", cloneErr),
)
svc.failDeployment(ctx, app, deployment, fmt.Errorf("failed to clone repo: %w", cloneErr))
return "", nil, fmt.Errorf("failed to clone repo: %w", cloneErr)
}

View File

@@ -32,10 +32,7 @@ func TestCleanupCancelledDeploy_RemovesBuildDir(t *testing.T) {
require.NoError(t, os.MkdirAll(deployDir, 0o750))
// Create a file inside to verify full removal
require.NoError(
t,
os.WriteFile(filepath.Join(deployDir, "work"), []byte("test"), 0o600),
)
require.NoError(t, os.WriteFile(filepath.Join(deployDir, "work"), []byte("test"), 0o600))
// Also create a dir for a different deployment (should NOT be removed)
otherDir := filepath.Join(buildDir, "99-xyz789")

View File

@@ -31,9 +31,7 @@ func TestBuildContainerOptionsUsesImageID(t *testing.T) {
const expectedImageID = docker.ImageID("sha256:abc123def456")
opts, err := svc.BuildContainerOptionsExported(
context.Background(), app, expectedImageID,
)
opts, err := svc.BuildContainerOptionsExported(context.Background(), app, expectedImageID)
if err != nil {
t.Fatalf("buildContainerOptions returned error: %v", err)
}
@@ -79,20 +77,14 @@ func TestBuildContainerOptionsNoResourceLimits(t *testing.T) {
}
}
// buildOptsForApp saves an app configured by setup and returns the container
// options built for it.
func buildOptsForApp(
t *testing.T,
name string,
setup func(app *models.App),
) docker.CreateContainerOptions {
t.Helper()
func TestBuildContainerOptionsCPULimit(t *testing.T) {
t.Parallel()
db := database.NewTestDatabase(t)
app := models.NewApp(db)
app.Name = name
setup(app)
app.Name = "cpulimit"
app.CPULimit = sql.NullFloat64{Float64: 0.5, Valid: true}
err := app.Save(context.Background())
if err != nil {
@@ -109,16 +101,6 @@ func buildOptsForApp(
t.Fatalf("buildContainerOptions returned error: %v", err)
}
return opts
}
func TestBuildContainerOptionsCPULimit(t *testing.T) {
t.Parallel()
opts := buildOptsForApp(t, "cpulimit", func(app *models.App) {
app.CPULimit = sql.NullFloat64{Float64: 0.5, Valid: true}
})
if opts.CPULimit != 0.5 {
t.Errorf("expected CPULimit=0.5, got %v", opts.CPULimit)
}
@@ -127,9 +109,26 @@ func TestBuildContainerOptionsCPULimit(t *testing.T) {
func TestBuildContainerOptionsMemoryLimit(t *testing.T) {
t.Parallel()
opts := buildOptsForApp(t, "memlimit", func(app *models.App) {
app.MemoryLimit = sql.NullInt64{Int64: 536870912, Valid: true} // 512m
})
db := database.NewTestDatabase(t)
app := models.NewApp(db)
app.Name = "memlimit"
app.MemoryLimit = sql.NullInt64{Int64: 536870912, Valid: true} // 512m
err := app.Save(context.Background())
if err != nil {
t.Fatalf("failed to save app: %v", err)
}
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
svc := deploy.NewTestService(log)
opts, err := svc.BuildContainerOptionsExported(
context.Background(), app, docker.ImageID("test:latest"),
)
if err != nil {
t.Fatalf("buildContainerOptions returned error: %v", err)
}
if opts.MemoryLimit != 536870912 {
t.Errorf("expected MemoryLimit=536870912, got %v", opts.MemoryLimit)

View File

@@ -26,11 +26,7 @@ func (svc *Service) CancelActiveDeploy(appID string) {
}
// RegisterActiveDeploy registers an active deploy for testing.
func (svc *Service) RegisterActiveDeploy(
appID string,
cancel context.CancelFunc,
done chan struct{},
) {
func (svc *Service) RegisterActiveDeploy(appID string, cancel context.CancelFunc, done chan struct{}) {
svc.activeDeploys.Store(appID, &activeDeploy{cancel: cancel, done: done})
}
@@ -45,11 +41,7 @@ func (svc *Service) UnlockApp(appID string) {
}
// NewTestServiceWithConfig creates a Service with config and docker client for testing.
func NewTestServiceWithConfig(
log *slog.Logger,
cfg *config.Config,
dockerClient *docker.Client,
) *Service {
func NewTestServiceWithConfig(log *slog.Logger, cfg *config.Config, dockerClient *docker.Client) *Service {
return &Service{
log: log,
config: cfg,

View File

@@ -159,8 +159,7 @@ func (svc *Service) NotifyDeployFailed(
) {
duration := time.Since(deployment.StartedAt)
title := "Deploy failed: " + app.Name
message := "Deployment failed after " + formatDuration(duration) +
": " + deployErr.Error()
message := "Deployment failed after " + formatDuration(duration) + ": " + deployErr.Error()
svc.sendNotifications(ctx, app, title, message, message, "error")
}
@@ -267,8 +266,7 @@ func (svc *Service) sendNtfy(
request.Header.Set("Title", title)
request.Header.Set("Priority", svc.ntfyPriority(priority))
// #nosec G704 -- URL from validated config, not user input
resp, err := svc.client.Do(request)
resp, err := svc.client.Do(request) // #nosec G704 -- URL from validated config, not user input
if err != nil {
return fmt.Errorf("failed to send ntfy request: %w", err)
}
@@ -365,8 +363,7 @@ func (svc *Service) sendSlack(
request.Header.Set("Content-Type", "application/json")
// #nosec G704 -- URL from validated config, not user input
resp, err := svc.client.Do(request)
resp, err := svc.client.Do(request) // #nosec G704 -- URL from validated config, not user input
if err != nil {
return fmt.Errorf("failed to send slack request: %w", err)
}

View File

@@ -98,77 +98,88 @@ type GitLabPushPayload struct {
func ParsePushPayload(source Source, payload []byte) (*PushEvent, error) {
switch source {
case SourceGitHub:
return parsePush(payload, githubPushEvent)
return parseGitHubPush(payload)
case SourceGitLab:
return parsePush(payload, gitlabPushEvent)
return parseGitLabPush(payload)
case SourceGitea, SourceUnknown:
// Gitea and unknown both use Gitea format for backward compatibility.
return parsePush(payload, giteaPushEvent)
return parseGiteaPush(payload)
}
// Unreachable for known source values, but satisfies exhaustive checker.
return parsePush(payload, giteaPushEvent)
return parseGiteaPush(payload)
}
// parsePush unmarshals payload into P and converts it into a normalized
// PushEvent via build.
func parsePush[P any](payload []byte, build func(P) *PushEvent) (*PushEvent, error) {
var p P
func parseGiteaPush(payload []byte) (*PushEvent, error) {
var p GiteaPushPayload
unmarshalErr := json.Unmarshal(payload, &p)
if unmarshalErr != nil {
return nil, unmarshalErr
}
return build(p), nil
}
commitURL := extractGiteaCommitURL(p)
// basePushEvent builds a PushEvent populated with the fields shared by all
// webhook sources.
func basePushEvent(source Source, ref, before, after string) *PushEvent {
return &PushEvent{
Source: source,
Ref: ref,
Before: before,
After: after,
Branch: extractBranch(ref),
Source: SourceGitea,
Ref: p.Ref,
Before: p.Before,
After: p.After,
Branch: extractBranch(p.Ref),
RepoName: p.Repository.FullName,
CloneURL: p.Repository.CloneURL,
HTMLURL: p.Repository.HTMLURL,
CommitURL: commitURL,
Pusher: p.Pusher.Username,
}, nil
}
func parseGitHubPush(payload []byte) (*PushEvent, error) {
var p GitHubPushPayload
unmarshalErr := json.Unmarshal(payload, &p)
if unmarshalErr != nil {
return nil, unmarshalErr
}
commitURL := extractGitHubCommitURL(p)
return &PushEvent{
Source: SourceGitHub,
Ref: p.Ref,
Before: p.Before,
After: p.After,
Branch: extractBranch(p.Ref),
RepoName: p.Repository.FullName,
CloneURL: p.Repository.CloneURL,
HTMLURL: p.Repository.HTMLURL,
CommitURL: commitURL,
Pusher: p.Pusher.Name,
}, nil
}
// giteaPushEvent converts a Gitea push payload to a normalized PushEvent.
func giteaPushEvent(p GiteaPushPayload) *PushEvent {
event := basePushEvent(SourceGitea, p.Ref, p.Before, p.After)
event.RepoName = p.Repository.FullName
event.CloneURL = p.Repository.CloneURL
event.HTMLURL = p.Repository.HTMLURL
event.CommitURL = extractGiteaCommitURL(p)
event.Pusher = p.Pusher.Username
func parseGitLabPush(payload []byte) (*PushEvent, error) {
var p GitLabPushPayload
return event
}
unmarshalErr := json.Unmarshal(payload, &p)
if unmarshalErr != nil {
return nil, unmarshalErr
}
// gitlabPushEvent converts a GitLab push payload to a normalized PushEvent.
func gitlabPushEvent(p GitLabPushPayload) *PushEvent {
event := basePushEvent(SourceGitLab, p.Ref, p.Before, p.After)
event.RepoName = p.Project.PathWithNamespace
event.CloneURL = p.Project.GitHTTPURL
event.HTMLURL = p.Project.WebURL
event.CommitURL = extractGitLabCommitURL(p)
event.Pusher = p.UserName
commitURL := extractGitLabCommitURL(p)
return event
}
// githubPushEvent converts a GitHub push payload to a normalized PushEvent.
func githubPushEvent(p GitHubPushPayload) *PushEvent {
event := basePushEvent(SourceGitHub, p.Ref, p.Before, p.After)
event.RepoName = p.Repository.FullName
event.CloneURL = p.Repository.CloneURL
event.HTMLURL = p.Repository.HTMLURL
event.CommitURL = extractGitHubCommitURL(p)
event.Pusher = p.Pusher.Name
return event
return &PushEvent{
Source: SourceGitLab,
Ref: p.Ref,
Before: p.Before,
After: p.After,
Branch: extractBranch(p.Ref),
RepoName: p.Project.PathWithNamespace,
CloneURL: p.Project.GitHTTPURL,
HTMLURL: p.Project.WebURL,
CommitURL: commitURL,
Pusher: p.UserName,
}, nil
}
// extractBranch extracts the branch name from a git ref.

View File

@@ -24,18 +24,6 @@ import (
"sneak.berlin/go/upaas/internal/service/webhook"
)
const (
giteaEventHeader = "X-Gitea-Event"
githubEventHeader = "X-GitHub-Event"
gitlabEventHeader = "X-Gitlab-Event"
gitlabPushHook = "Push Hook"
pushEventType = "push"
branchMain = "main"
refMain = "refs/heads/main"
testCommitSHA = "abc123def456789"
testPusher = "developer"
)
type testDeps struct {
logger *logger.Logger
config *config.Config
@@ -57,14 +45,9 @@ func setupTestDeps(t *testing.T) *testDeps {
loggerInst, err := logger.New(fx.Lifecycle(nil), logger.Params{Globals: globalsInst})
require.NoError(t, err)
cfg := &config.Config{
Port: 8080, DataDir: tmpDir,
SessionSecret: "test-secret-key-at-least-32-chars",
}
cfg := &config.Config{Port: 8080, DataDir: tmpDir, SessionSecret: "test-secret-key-at-least-32-chars"}
dbInst, err := database.New(
fx.Lifecycle(nil), database.Params{Logger: loggerInst, Config: cfg},
)
dbInst, err := database.New(fx.Lifecycle(nil), database.Params{Logger: loggerInst, Config: cfg})
require.NoError(t, err)
return &testDeps{logger: loggerInst, config: cfg, db: dbInst, tmpDir: tmpDir}
@@ -75,19 +58,14 @@ func setupTestService(t *testing.T) (*webhook.Service, *database.Database, func(
deps := setupTestDeps(t)
dockerClient, err := docker.New(
fx.Lifecycle(nil), docker.Params{Logger: deps.logger, Config: deps.config},
)
dockerClient, err := docker.New(fx.Lifecycle(nil), docker.Params{Logger: deps.logger, Config: deps.config})
require.NoError(t, err)
notifySvc, err := notify.New(
fx.Lifecycle(nil), notify.ServiceParams{Logger: deps.logger},
)
notifySvc, err := notify.New(fx.Lifecycle(nil), notify.ServiceParams{Logger: deps.logger})
require.NoError(t, err)
deploySvc, err := deploy.New(fx.Lifecycle(nil), deploy.ServiceParams{
Logger: deps.logger, Config: deps.config, Database: deps.db,
Docker: dockerClient, Notify: notifySvc,
Logger: deps.logger, Config: deps.config, Database: deps.db, Docker: dockerClient, Notify: notifySvc,
})
require.NoError(t, err)
@@ -126,6 +104,8 @@ func createTestApp(
}
// TestDetectWebhookSource tests auto-detection of webhook source from HTTP headers.
//
//nolint:funlen // table-driven test with comprehensive test cases
func TestDetectWebhookSource(testingT *testing.T) {
testingT.Parallel()
@@ -136,17 +116,17 @@ func TestDetectWebhookSource(testingT *testing.T) {
}{
{
name: "detects Gitea from X-Gitea-Event header",
headers: map[string]string{giteaEventHeader: pushEventType},
headers: map[string]string{"X-Gitea-Event": "push"},
expected: webhook.SourceGitea,
},
{
name: "detects GitHub from X-GitHub-Event header",
headers: map[string]string{githubEventHeader: pushEventType},
headers: map[string]string{"X-GitHub-Event": "push"},
expected: webhook.SourceGitHub,
},
{
name: "detects GitLab from X-Gitlab-Event header",
headers: map[string]string{gitlabEventHeader: gitlabPushHook},
headers: map[string]string{"X-Gitlab-Event": "Push Hook"},
expected: webhook.SourceGitLab,
},
{
@@ -162,16 +142,16 @@ func TestDetectWebhookSource(testingT *testing.T) {
{
name: "Gitea takes precedence over GitHub",
headers: map[string]string{
giteaEventHeader: pushEventType,
githubEventHeader: pushEventType,
"X-Gitea-Event": "push",
"X-GitHub-Event": "push",
},
expected: webhook.SourceGitea,
},
{
name: "GitHub takes precedence over GitLab",
headers: map[string]string{
githubEventHeader: pushEventType,
gitlabEventHeader: gitlabPushHook,
"X-GitHub-Event": "push",
"X-Gitlab-Event": "Push Hook",
},
expected: webhook.SourceGitHub,
},
@@ -204,33 +184,33 @@ func TestDetectEventType(testingT *testing.T) {
}{
{
name: "extracts Gitea event type",
headers: map[string]string{giteaEventHeader: pushEventType},
headers: map[string]string{"X-Gitea-Event": "push"},
source: webhook.SourceGitea,
expected: pushEventType,
expected: "push",
},
{
name: "extracts GitHub event type",
headers: map[string]string{githubEventHeader: pushEventType},
headers: map[string]string{"X-GitHub-Event": "push"},
source: webhook.SourceGitHub,
expected: pushEventType,
expected: "push",
},
{
name: "extracts GitLab event type",
headers: map[string]string{gitlabEventHeader: gitlabPushHook},
headers: map[string]string{"X-Gitlab-Event": "Push Hook"},
source: webhook.SourceGitLab,
expected: gitlabPushHook,
expected: "Push Hook",
},
{
name: "returns push for unknown source",
headers: map[string]string{},
source: webhook.SourceUnknown,
expected: pushEventType,
expected: "push",
},
{
name: "returns push when header missing for source",
headers: map[string]string{},
source: webhook.SourceGitea,
expected: pushEventType,
expected: "push",
},
}
@@ -270,54 +250,11 @@ func TestUnparsedURLString(t *testing.T) {
assert.Empty(t, empty.String())
}
// pushEventExpectation describes the expected normalized fields of a parsed
// push payload.
type pushEventExpectation struct {
source webhook.Source
ref string
branch string
after string
repoName string
cloneURL webhook.UnparsedURL
htmlURL webhook.UnparsedURL
commitURL webhook.UnparsedURL
pusher string
}
// TestParsePushPayloadGitea tests parsing of Gitea push payloads.
func TestParsePushPayloadGitea(t *testing.T) {
t.Parallel()
// assertPushEvent parses payload for want.source and asserts every
// normalized PushEvent field matches want.
func assertPushEvent(t *testing.T, payload []byte, want pushEventExpectation) {
t.Helper()
event, err := webhook.ParsePushPayload(want.source, payload)
require.NoError(t, err)
assert.Equal(t, want.source, event.Source)
assert.Equal(t, want.ref, event.Ref)
assert.Equal(t, want.branch, event.Branch)
assert.Equal(t, want.after, event.After)
assertPushEventOrigin(t, event, want)
}
// assertPushEventOrigin asserts the repository and pusher fields of event.
func assertPushEventOrigin(
t *testing.T,
event *webhook.PushEvent,
want pushEventExpectation,
) {
t.Helper()
assert.Equal(t, want.repoName, event.RepoName)
assert.Equal(t, want.cloneURL, event.CloneURL)
assert.Equal(t, want.htmlURL, event.HTMLURL)
assert.Equal(t, want.commitURL, event.CommitURL)
assert.Equal(t, want.pusher, event.Pusher)
}
// giteaPushJSON returns a realistic Gitea push webhook payload.
func giteaPushJSON() []byte {
return []byte(`{
payload := []byte(`{
"ref": "refs/heads/main",
"before": "0000000000000000000000000000000000000000",
"after": "abc123def456789",
@@ -338,11 +275,29 @@ func giteaPushJSON() []byte {
}
]
}`)
event, err := webhook.ParsePushPayload(webhook.SourceGitea, payload)
require.NoError(t, err)
assert.Equal(t, webhook.SourceGitea, event.Source)
assert.Equal(t, "refs/heads/main", event.Ref)
assert.Equal(t, "main", event.Branch)
assert.Equal(t, "abc123def456789", event.After)
assert.Equal(t, "myorg/myrepo", event.RepoName)
assert.Equal(t, webhook.UnparsedURL("https://gitea.example.com/myorg/myrepo.git"), event.CloneURL)
assert.Equal(t, webhook.UnparsedURL("https://gitea.example.com/myorg/myrepo"), event.HTMLURL)
assert.Equal(t,
webhook.UnparsedURL("https://gitea.example.com/myorg/myrepo/commit/abc123def456789"),
event.CommitURL,
)
assert.Equal(t, "developer", event.Pusher)
}
// githubPushJSON returns a realistic GitHub push webhook payload.
func githubPushJSON() []byte {
return []byte(`{
// TestParsePushPayloadGitHub tests parsing of GitHub push payloads.
func TestParsePushPayloadGitHub(t *testing.T) {
t.Parallel()
payload := []byte(`{
"ref": "refs/heads/main",
"before": "0000000000000000000000000000000000000000",
"after": "abc123def456789",
@@ -368,11 +323,29 @@ func githubPushJSON() []byte {
}
]
}`)
event, err := webhook.ParsePushPayload(webhook.SourceGitHub, payload)
require.NoError(t, err)
assert.Equal(t, webhook.SourceGitHub, event.Source)
assert.Equal(t, "refs/heads/main", event.Ref)
assert.Equal(t, "main", event.Branch)
assert.Equal(t, "abc123def456789", event.After)
assert.Equal(t, "myorg/myrepo", event.RepoName)
assert.Equal(t, webhook.UnparsedURL("https://github.com/myorg/myrepo.git"), event.CloneURL)
assert.Equal(t, webhook.UnparsedURL("https://github.com/myorg/myrepo"), event.HTMLURL)
assert.Equal(t,
webhook.UnparsedURL("https://github.com/myorg/myrepo/commit/abc123def456789"),
event.CommitURL,
)
assert.Equal(t, "developer", event.Pusher)
}
// gitlabPushJSON returns a realistic GitLab push webhook payload.
func gitlabPushJSON() []byte {
return []byte(`{
// TestParsePushPayloadGitLab tests parsing of GitLab push payloads.
func TestParsePushPayloadGitLab(t *testing.T) {
t.Parallel()
payload := []byte(`{
"ref": "refs/heads/develop",
"before": "0000000000000000000000000000000000000000",
"after": "abc123def456789",
@@ -393,78 +366,25 @@ func gitlabPushJSON() []byte {
}
]
}`)
event, err := webhook.ParsePushPayload(webhook.SourceGitLab, payload)
require.NoError(t, err)
assert.Equal(t, webhook.SourceGitLab, event.Source)
assert.Equal(t, "refs/heads/develop", event.Ref)
assert.Equal(t, "develop", event.Branch)
assert.Equal(t, "abc123def456789", event.After)
assert.Equal(t, "mygroup/myproject", event.RepoName)
assert.Equal(t, webhook.UnparsedURL("https://gitlab.com/mygroup/myproject.git"), event.CloneURL)
assert.Equal(t, webhook.UnparsedURL("https://gitlab.com/mygroup/myproject"), event.HTMLURL)
assert.Equal(t,
webhook.UnparsedURL("https://gitlab.com/mygroup/myproject/-/commit/abc123def456789"),
event.CommitURL,
)
assert.Equal(t, "developer", event.Pusher)
}
// pushPayloadJSON returns the push payload fixture for source.
func pushPayloadJSON(t *testing.T, source webhook.Source) []byte {
t.Helper()
switch source {
case webhook.SourceGitHub:
return githubPushJSON()
case webhook.SourceGitLab:
return gitlabPushJSON()
case webhook.SourceGitea, webhook.SourceUnknown:
return giteaPushJSON()
}
t.Fatalf("no push payload fixture for source %v", source)
return nil
}
// TestParsePushPayload tests parsing of Gitea, GitHub, and GitLab push
// payloads into normalized PushEvents.
func TestParsePushPayload(testingT *testing.T) {
testingT.Parallel()
tests := []pushEventExpectation{
{
source: webhook.SourceGitea,
ref: refMain,
branch: branchMain,
after: testCommitSHA,
repoName: "myorg/myrepo",
cloneURL: "https://gitea.example.com/myorg/myrepo.git",
htmlURL: "https://gitea.example.com/myorg/myrepo",
commitURL: "https://gitea.example.com/myorg/myrepo/commit/abc123def456789",
pusher: testPusher,
},
{
source: webhook.SourceGitHub,
ref: refMain,
branch: branchMain,
after: testCommitSHA,
repoName: "myorg/myrepo",
cloneURL: "https://github.com/myorg/myrepo.git",
htmlURL: "https://github.com/myorg/myrepo",
commitURL: "https://github.com/myorg/myrepo/commit/abc123def456789",
pusher: testPusher,
},
{
source: webhook.SourceGitLab,
ref: "refs/heads/develop",
branch: "develop",
after: testCommitSHA,
repoName: "mygroup/myproject",
cloneURL: "https://gitlab.com/mygroup/myproject.git",
htmlURL: "https://gitlab.com/mygroup/myproject",
commitURL: "https://gitlab.com/mygroup/myproject/-/commit/abc123def456789",
pusher: testPusher,
},
}
for _, testCase := range tests {
testingT.Run(testCase.source.String(), func(t *testing.T) {
t.Parallel()
assertPushEvent(t, pushPayloadJSON(t, testCase.source), testCase)
})
}
}
// TestParsePushPayloadUnknownFallsBackToGitea tests that unknown source
// uses the Gitea parser.
// TestParsePushPayloadUnknownFallsBackToGitea tests that unknown source uses Gitea parser.
func TestParsePushPayloadUnknownFallsBackToGitea(t *testing.T) {
t.Parallel()
@@ -479,7 +399,7 @@ func TestParsePushPayloadUnknownFallsBackToGitea(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, webhook.SourceGitea, event.Source)
assert.Equal(t, branchMain, event.Branch)
assert.Equal(t, "main", event.Branch)
assert.Equal(t, "abc123", event.After)
}
@@ -542,10 +462,7 @@ func TestGitHubCommitURLFallback(t *testing.T) {
event, err := webhook.ParsePushPayload(webhook.SourceGitHub, payload)
require.NoError(t, err)
assert.Equal(t,
webhook.UnparsedURL("https://github.com/u/r/commit/abc123"),
event.CommitURL,
)
assert.Equal(t, webhook.UnparsedURL("https://github.com/u/r/commit/abc123"), event.CommitURL)
})
t.Run("falls back to commits list", func(t *testing.T) {
@@ -560,10 +477,7 @@ func TestGitHubCommitURLFallback(t *testing.T) {
event, err := webhook.ParsePushPayload(webhook.SourceGitHub, payload)
require.NoError(t, err)
assert.Equal(t,
webhook.UnparsedURL("https://github.com/u/r/commit/abc123"),
event.CommitURL,
)
assert.Equal(t, webhook.UnparsedURL("https://github.com/u/r/commit/abc123"), event.CommitURL)
})
t.Run("constructs URL from repo HTML URL", func(t *testing.T) {
@@ -577,10 +491,7 @@ func TestGitHubCommitURLFallback(t *testing.T) {
event, err := webhook.ParsePushPayload(webhook.SourceGitHub, payload)
require.NoError(t, err)
assert.Equal(t,
webhook.UnparsedURL("https://github.com/u/r/commit/abc123"),
event.CommitURL,
)
assert.Equal(t, webhook.UnparsedURL("https://github.com/u/r/commit/abc123"), event.CommitURL)
})
}
@@ -600,10 +511,7 @@ func TestGitLabCommitURLFallback(t *testing.T) {
event, err := webhook.ParsePushPayload(webhook.SourceGitLab, payload)
require.NoError(t, err)
assert.Equal(t,
webhook.UnparsedURL("https://gitlab.com/g/p/-/commit/abc123"),
event.CommitURL,
)
assert.Equal(t, webhook.UnparsedURL("https://gitlab.com/g/p/-/commit/abc123"), event.CommitURL)
})
t.Run("constructs URL from project web URL", func(t *testing.T) {
@@ -617,10 +525,7 @@ func TestGitLabCommitURLFallback(t *testing.T) {
event, err := webhook.ParsePushPayload(webhook.SourceGitLab, payload)
require.NoError(t, err)
assert.Equal(t,
webhook.UnparsedURL("https://gitlab.com/g/p/-/commit/abc123"),
event.CommitURL,
)
assert.Equal(t, webhook.UnparsedURL("https://gitlab.com/g/p/-/commit/abc123"), event.CommitURL)
})
}
@@ -683,8 +588,7 @@ func TestGiteaPushPayloadParsing(testingT *testing.T) {
})
}
// TestGitHubPushPayloadParsing tests deserialization of the GitHub payload
// struct.
// TestGitHubPushPayloadParsing tests direct deserialization of the GitHub payload struct.
func TestGitHubPushPayloadParsing(t *testing.T) {
t.Parallel()
@@ -729,8 +633,7 @@ func TestGitHubPushPayloadParsing(t *testing.T) {
assert.Len(t, p.Commits, 1)
}
// TestGitLabPushPayloadParsing tests deserialization of the GitLab payload
// struct.
// TestGitLabPushPayloadParsing tests direct deserialization of the GitLab payload struct.
func TestGitLabPushPayloadParsing(t *testing.T) {
t.Parallel()
@@ -768,8 +671,9 @@ func TestGitLabPushPayloadParsing(t *testing.T) {
assert.Len(t, p.Commits, 1)
}
// TestExtractBranch tests branch extraction via HandleWebhook integration
// (extractBranch is unexported).
// TestExtractBranch tests branch extraction via HandleWebhook integration (extractBranch is unexported).
//
//nolint:funlen // table-driven test with comprehensive test cases
func TestExtractBranch(testingT *testing.T) {
testingT.Parallel()
@@ -780,8 +684,8 @@ func TestExtractBranch(testingT *testing.T) {
}{
{
name: "extracts main branch",
ref: refMain,
expected: branchMain,
ref: "refs/heads/main",
expected: "main",
},
{
name: "extracts feature branch",
@@ -795,8 +699,8 @@ func TestExtractBranch(testingT *testing.T) {
},
{
name: "returns raw ref if no prefix",
ref: branchMain,
expected: branchMain,
ref: "main",
expected: "main",
},
{
name: "handles empty ref",
@@ -824,7 +728,7 @@ func TestExtractBranch(testingT *testing.T) {
payload := []byte(`{"ref": "` + testCase.ref + `"}`)
err := svc.HandleWebhook(
context.Background(), app, webhook.SourceGitea, pushEventType, payload,
context.Background(), app, webhook.SourceGitea, "push", payload,
)
require.NoError(t, err)
@@ -846,7 +750,7 @@ func TestHandleWebhookMatchingBranch(t *testing.T) {
svc, dbInst, cleanup := setupTestService(t)
defer cleanup()
app := createTestApp(t, dbInst, branchMain)
app := createTestApp(t, dbInst, "main")
payload := []byte(`{
"ref": "refs/heads/main",
@@ -863,7 +767,7 @@ func TestHandleWebhookMatchingBranch(t *testing.T) {
}`)
err := svc.HandleWebhook(
context.Background(), app, webhook.SourceGitea, pushEventType, payload,
context.Background(), app, webhook.SourceGitea, "push", payload,
)
require.NoError(t, err)
@@ -875,8 +779,8 @@ func TestHandleWebhookMatchingBranch(t *testing.T) {
require.Len(t, events, 1)
event := events[0]
assert.Equal(t, pushEventType, event.EventType)
assert.Equal(t, branchMain, event.Branch)
assert.Equal(t, "push", event.EventType)
assert.Equal(t, "main", event.Branch)
assert.True(t, event.Matched)
assert.Equal(t, "abc123def456", event.CommitSHA.String)
}
@@ -887,12 +791,12 @@ func TestHandleWebhookNonMatchingBranch(t *testing.T) {
svc, dbInst, cleanup := setupTestService(t)
defer cleanup()
app := createTestApp(t, dbInst, branchMain)
app := createTestApp(t, dbInst, "main")
payload := []byte(`{"ref": "refs/heads/develop", "after": "def789ghi012"}`)
err := svc.HandleWebhook(
context.Background(), app, webhook.SourceGitea, pushEventType, payload,
context.Background(), app, webhook.SourceGitea, "push", payload,
)
require.NoError(t, err)
@@ -910,11 +814,10 @@ func TestHandleWebhookInvalidJSON(t *testing.T) {
svc, dbInst, cleanup := setupTestService(t)
defer cleanup()
app := createTestApp(t, dbInst, branchMain)
app := createTestApp(t, dbInst, "main")
err := svc.HandleWebhook(
context.Background(), app, webhook.SourceGitea, pushEventType,
[]byte(`{invalid json}`),
context.Background(), app, webhook.SourceGitea, "push", []byte(`{invalid json}`),
)
require.NoError(t, err)
@@ -929,10 +832,10 @@ func TestHandleWebhookEmptyPayload(t *testing.T) {
svc, dbInst, cleanup := setupTestService(t)
defer cleanup()
app := createTestApp(t, dbInst, branchMain)
app := createTestApp(t, dbInst, "main")
err := svc.HandleWebhook(
context.Background(), app, webhook.SourceGitea, pushEventType, []byte(`{}`),
context.Background(), app, webhook.SourceGitea, "push", []byte(`{}`),
)
require.NoError(t, err)
@@ -942,43 +845,14 @@ func TestHandleWebhookEmptyPayload(t *testing.T) {
assert.False(t, events[0].Matched)
}
// assertHandleWebhookDeploys runs HandleWebhook for payload against a fresh
// app on branchMain and asserts the recorded event matched with the given
// commit SHA and commit URL.
func assertHandleWebhookDeploys(
t *testing.T,
source webhook.Source,
payload []byte,
wantSHA string,
wantCommitURL string,
) {
t.Helper()
// TestHandleWebhookGitHubSource tests HandleWebhook with a GitHub push payload.
func TestHandleWebhookGitHubSource(t *testing.T) {
t.Parallel()
svc, dbInst, cleanup := setupTestService(t)
defer cleanup()
app := createTestApp(t, dbInst, branchMain)
err := svc.HandleWebhook(context.Background(), app, source, pushEventType, payload)
require.NoError(t, err)
// Allow async deployment goroutine to complete before test cleanup
time.Sleep(100 * time.Millisecond)
events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err)
require.Len(t, events, 1)
event := events[0]
assert.Equal(t, branchMain, event.Branch)
assert.True(t, event.Matched)
assert.Equal(t, wantSHA, event.CommitSHA.String)
assert.Equal(t, wantCommitURL, event.CommitURL.String)
}
// TestHandleWebhookGitHubSource tests HandleWebhook with a GitHub push payload.
func TestHandleWebhookGitHubSource(t *testing.T) {
t.Parallel()
app := createTestApp(t, dbInst, "main")
payload := []byte(`{
"ref": "refs/heads/main",
@@ -996,16 +870,34 @@ func TestHandleWebhookGitHubSource(t *testing.T) {
}
}`)
assertHandleWebhookDeploys(
t, webhook.SourceGitHub, payload,
"github123", "https://github.com/org/repo/commit/github123",
err := svc.HandleWebhook(
context.Background(), app, webhook.SourceGitHub, "push", payload,
)
require.NoError(t, err)
// Allow async deployment goroutine to complete before test cleanup
time.Sleep(100 * time.Millisecond)
events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err)
require.Len(t, events, 1)
event := events[0]
assert.Equal(t, "main", event.Branch)
assert.True(t, event.Matched)
assert.Equal(t, "github123", event.CommitSHA.String)
assert.Equal(t, "https://github.com/org/repo/commit/github123", event.CommitURL.String)
}
// TestHandleWebhookGitLabSource tests HandleWebhook with a GitLab push payload.
func TestHandleWebhookGitLabSource(t *testing.T) {
t.Parallel()
svc, dbInst, cleanup := setupTestService(t)
defer cleanup()
app := createTestApp(t, dbInst, "main")
payload := []byte(`{
"ref": "refs/heads/main",
"after": "gitlab456",
@@ -1025,10 +917,23 @@ func TestHandleWebhookGitLabSource(t *testing.T) {
]
}`)
assertHandleWebhookDeploys(
t, webhook.SourceGitLab, payload,
"gitlab456", "https://gitlab.com/group/project/-/commit/gitlab456",
err := svc.HandleWebhook(
context.Background(), app, webhook.SourceGitLab, "push", payload,
)
require.NoError(t, err)
// Allow async deployment goroutine to complete before test cleanup
time.Sleep(100 * time.Millisecond)
events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err)
require.Len(t, events, 1)
event := events[0]
assert.Equal(t, "main", event.Branch)
assert.True(t, event.Matched)
assert.Equal(t, "gitlab456", event.CommitSHA.String)
assert.Equal(t, "https://gitlab.com/group/project/-/commit/gitlab456", event.CommitURL.String)
}
// TestSetupTestService verifies the test helper creates a working test service.
@@ -1057,10 +962,10 @@ func TestPushEventConstruction(t *testing.T) {
event := webhook.PushEvent{
Source: webhook.SourceGitHub,
Ref: refMain,
Ref: "refs/heads/main",
Before: "000",
After: "abc",
Branch: branchMain,
Branch: "main",
RepoName: "org/repo",
CloneURL: webhook.UnparsedURL("https://github.com/org/repo.git"),
HTMLURL: webhook.UnparsedURL("https://github.com/org/repo"),
@@ -1068,7 +973,7 @@ func TestPushEventConstruction(t *testing.T) {
Pusher: "user",
}
assert.Equal(t, branchMain, event.Branch)
assert.Equal(t, "main", event.Branch)
assert.Equal(t, webhook.SourceGitHub, event.Source)
assert.Equal(t, "abc", event.After)
}

View File

@@ -10,11 +10,11 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Pinned versions, 2026-08-07. Never "latest"; exact versions only.
GOLANGCI_LINT_VERSION="2.12.2"
# sha256 of golangci-lint-2.12.2-linux-<arch>.tar.gz release archives
GOLANGCI_LINT_SHA256_AMD64="8df580d2670fed8fa984aac0507099af8df275e665215f5c7a2ae3943893a553"
GOLANGCI_LINT_SHA256_ARM64="44cd40a8c76c86755375adfeea52cfd3533cb43d7bd647771e0ae065e166df3a"
# Pinned versions, 2026-07-07. Never "latest"; exact versions only.
GOLANGCI_LINT_VERSION="2.10.1"
# sha256 of golangci-lint-2.10.1-linux-<arch>.tar.gz release archives
GOLANGCI_LINT_SHA256_AMD64="dfa775874cf0561b404a02a8f4481fc69b28091da95aa697259820d429b09c99"
GOLANGCI_LINT_SHA256_ARM64="6652b42ae02915eb2f9cb2a2e0cac99514c8eded8388d88ae3e06e1a52c00de8"
PKGMGR=""
SUDO=""