The deploy-log download handler passed a request-derived path to http.ServeFile, which gosec flags as G703 (path traversal via taint). The handler now opens the log through an os.Root confined to the deploy log directory, so any escaping path is rejected at runtime (404) and the file is streamed with http.ServeContent. A regression test plants a sentinel outside the log dir and asserts the traversal is refused and its contents never served; removing the guard makes that test fail. No //nolint used. Model: opus-4-8
1221 lines
32 KiB
Go
1221 lines
32 KiB
Go
package handlers_test
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.uber.org/fx"
|
|
|
|
"sneak.berlin/go/upaas/internal/models"
|
|
|
|
"sneak.berlin/go/upaas/internal/config"
|
|
"sneak.berlin/go/upaas/internal/database"
|
|
"sneak.berlin/go/upaas/internal/docker"
|
|
"sneak.berlin/go/upaas/internal/globals"
|
|
"sneak.berlin/go/upaas/internal/handlers"
|
|
"sneak.berlin/go/upaas/internal/healthcheck"
|
|
"sneak.berlin/go/upaas/internal/logger"
|
|
"sneak.berlin/go/upaas/internal/middleware"
|
|
"sneak.berlin/go/upaas/internal/service/app"
|
|
"sneak.berlin/go/upaas/internal/service/auth"
|
|
"sneak.berlin/go/upaas/internal/service/deploy"
|
|
"sneak.berlin/go/upaas/internal/service/notify"
|
|
"sneak.berlin/go/upaas/internal/service/webhook"
|
|
)
|
|
|
|
const (
|
|
branchMain = "main"
|
|
paramSecret = "secret"
|
|
)
|
|
|
|
type testContext struct {
|
|
handlers *handlers.Handlers
|
|
database *database.Database
|
|
authSvc *auth.Service
|
|
appSvc *app.Service
|
|
deploySvc *deploy.Service
|
|
middleware *middleware.Middleware
|
|
}
|
|
|
|
func createTestConfig(t *testing.T) *config.Config {
|
|
t.Helper()
|
|
|
|
return &config.Config{
|
|
Port: 8080,
|
|
DataDir: t.TempDir(),
|
|
SessionSecret: "test-secret-key-at-least-32-characters-long",
|
|
}
|
|
}
|
|
|
|
func createCoreServices(
|
|
t *testing.T,
|
|
cfg *config.Config,
|
|
) (*globals.Globals, *logger.Logger, *database.Database, *healthcheck.Healthcheck) {
|
|
t.Helper()
|
|
|
|
globals.SetAppname("upaas-test")
|
|
globals.SetVersion("test")
|
|
|
|
globalInstance, globErr := globals.New(fx.Lifecycle(nil))
|
|
require.NoError(t, globErr)
|
|
|
|
logInstance, logErr := logger.New(
|
|
fx.Lifecycle(nil),
|
|
logger.Params{Globals: globalInstance},
|
|
)
|
|
require.NoError(t, logErr)
|
|
|
|
dbInstance, dbErr := database.New(fx.Lifecycle(nil), database.Params{
|
|
Logger: logInstance,
|
|
Config: cfg,
|
|
})
|
|
require.NoError(t, dbErr)
|
|
|
|
hcInstance, hcErr := healthcheck.New(
|
|
fx.Lifecycle(nil),
|
|
healthcheck.Params{
|
|
Logger: logInstance,
|
|
Globals: globalInstance,
|
|
Config: cfg,
|
|
},
|
|
)
|
|
require.NoError(t, hcErr)
|
|
|
|
return globalInstance, logInstance, dbInstance, hcInstance
|
|
}
|
|
|
|
func createAppServices(
|
|
t *testing.T,
|
|
logInstance *logger.Logger,
|
|
dbInstance *database.Database,
|
|
cfg *config.Config,
|
|
) (*auth.Service, *app.Service, *deploy.Service, *webhook.Service, *docker.Client) {
|
|
t.Helper()
|
|
|
|
authSvc, authErr := auth.New(fx.Lifecycle(nil), auth.ServiceParams{
|
|
Logger: logInstance,
|
|
Config: cfg,
|
|
Database: dbInstance,
|
|
})
|
|
require.NoError(t, authErr)
|
|
|
|
appSvc, appErr := app.New(fx.Lifecycle(nil), app.ServiceParams{
|
|
Logger: logInstance,
|
|
Database: dbInstance,
|
|
})
|
|
require.NoError(t, appErr)
|
|
|
|
dockerClient, dockerErr := docker.New(fx.Lifecycle(nil), docker.Params{
|
|
Logger: logInstance,
|
|
Config: cfg,
|
|
})
|
|
require.NoError(t, dockerErr)
|
|
|
|
notifySvc, notifyErr := notify.New(fx.Lifecycle(nil), notify.ServiceParams{
|
|
Logger: logInstance,
|
|
})
|
|
require.NoError(t, notifyErr)
|
|
|
|
deploySvc, deployErr := deploy.New(fx.Lifecycle(nil), deploy.ServiceParams{
|
|
Logger: logInstance,
|
|
Config: cfg,
|
|
Database: dbInstance,
|
|
Docker: dockerClient,
|
|
Notify: notifySvc,
|
|
})
|
|
require.NoError(t, deployErr)
|
|
|
|
webhookSvc, webhookErr := webhook.New(fx.Lifecycle(nil), webhook.ServiceParams{
|
|
Logger: logInstance,
|
|
Database: dbInstance,
|
|
Deploy: deploySvc,
|
|
})
|
|
require.NoError(t, webhookErr)
|
|
|
|
return authSvc, appSvc, deploySvc, webhookSvc, dockerClient
|
|
}
|
|
|
|
func setupTestHandlers(t *testing.T) *testContext {
|
|
t.Helper()
|
|
|
|
cfg := createTestConfig(t)
|
|
|
|
globalInstance, logInstance, dbInstance, hcInstance := createCoreServices(t, cfg)
|
|
|
|
authSvc, appSvc, deploySvc, webhookSvc, dockerClient := createAppServices(
|
|
t,
|
|
logInstance,
|
|
dbInstance,
|
|
cfg,
|
|
)
|
|
|
|
handlersInstance, handlerErr := handlers.New(
|
|
fx.Lifecycle(nil),
|
|
handlers.Params{
|
|
Logger: logInstance,
|
|
Globals: globalInstance,
|
|
Database: dbInstance,
|
|
Healthcheck: hcInstance,
|
|
Auth: authSvc,
|
|
App: appSvc,
|
|
Deploy: deploySvc,
|
|
Webhook: webhookSvc,
|
|
Docker: dockerClient,
|
|
},
|
|
)
|
|
require.NoError(t, handlerErr)
|
|
|
|
mw, mwErr := middleware.New(fx.Lifecycle(nil), middleware.Params{
|
|
Logger: logInstance,
|
|
Globals: globalInstance,
|
|
Config: cfg,
|
|
Auth: authSvc,
|
|
})
|
|
require.NoError(t, mwErr)
|
|
|
|
return &testContext{
|
|
handlers: handlersInstance,
|
|
database: dbInstance,
|
|
authSvc: authSvc,
|
|
appSvc: appSvc,
|
|
deploySvc: deploySvc,
|
|
middleware: mw,
|
|
}
|
|
}
|
|
|
|
func TestHandleHealthCheck(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("returns health check response", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCtx := setupTestHandlers(t)
|
|
|
|
request := httptest.NewRequestWithContext(
|
|
t.Context(),
|
|
http.MethodGet,
|
|
"/.well-known/healthcheck.json",
|
|
nil,
|
|
)
|
|
recorder := httptest.NewRecorder()
|
|
|
|
handler := testCtx.handlers.HandleHealthCheck()
|
|
handler.ServeHTTP(recorder, request)
|
|
|
|
assert.Equal(t, http.StatusOK, recorder.Code)
|
|
assert.Contains(t, recorder.Header().Get("Content-Type"), "application/json")
|
|
assert.Contains(t, recorder.Body.String(), "status")
|
|
assert.Contains(t, recorder.Body.String(), "ok")
|
|
})
|
|
}
|
|
|
|
// 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()
|
|
|
|
t.Run("renders setup page", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCtx := setupTestHandlers(t)
|
|
assertPageRenders(t, testCtx.handlers.HandleSetupGET(), "/setup", "setup")
|
|
})
|
|
}
|
|
|
|
func createSetupFormRequest(
|
|
username, password, confirm string,
|
|
) *http.Request {
|
|
form := url.Values{}
|
|
form.Set("username", username)
|
|
form.Set("password", password)
|
|
form.Set("password_confirm", confirm)
|
|
|
|
request := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodPost,
|
|
"/setup",
|
|
strings.NewReader(form.Encode()),
|
|
)
|
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
return request
|
|
}
|
|
|
|
func TestHandleSetupPOSTCreatesUserAndRedirects(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCtx := setupTestHandlers(t)
|
|
|
|
request := createSetupFormRequest("admin", "password123", "password123")
|
|
recorder := httptest.NewRecorder()
|
|
|
|
handler := testCtx.handlers.HandleSetupPOST()
|
|
handler.ServeHTTP(recorder, request)
|
|
|
|
assert.Equal(t, http.StatusSeeOther, recorder.Code)
|
|
assert.Equal(t, "/", recorder.Header().Get("Location"))
|
|
}
|
|
|
|
func TestHandleSetupPOSTRejectsEmptyUsername(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCtx := setupTestHandlers(t)
|
|
|
|
request := createSetupFormRequest("", "password123", "password123")
|
|
recorder := httptest.NewRecorder()
|
|
|
|
handler := testCtx.handlers.HandleSetupPOST()
|
|
handler.ServeHTTP(recorder, request)
|
|
|
|
assert.Equal(t, http.StatusOK, recorder.Code)
|
|
assert.Contains(t, recorder.Body.String(), "required")
|
|
}
|
|
|
|
func TestHandleSetupPOSTRejectsShortPassword(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCtx := setupTestHandlers(t)
|
|
|
|
request := createSetupFormRequest("admin", "short", "short")
|
|
recorder := httptest.NewRecorder()
|
|
|
|
handler := testCtx.handlers.HandleSetupPOST()
|
|
handler.ServeHTTP(recorder, request)
|
|
|
|
assert.Equal(t, http.StatusOK, recorder.Code)
|
|
assert.Contains(t, recorder.Body.String(), "8 characters")
|
|
}
|
|
|
|
func TestHandleSetupPOSTRejectsMismatchedPasswords(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCtx := setupTestHandlers(t)
|
|
|
|
request := createSetupFormRequest("admin", "password123", "different123")
|
|
recorder := httptest.NewRecorder()
|
|
|
|
handler := testCtx.handlers.HandleSetupPOST()
|
|
handler.ServeHTTP(recorder, request)
|
|
|
|
assert.Equal(t, http.StatusOK, recorder.Code)
|
|
assert.Contains(t, recorder.Body.String(), "do not match")
|
|
}
|
|
|
|
func TestHandleLoginGET(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("renders login page", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCtx := setupTestHandlers(t)
|
|
assertPageRenders(t, testCtx.handlers.HandleLoginGET(), "/login", "login")
|
|
})
|
|
}
|
|
|
|
func createLoginFormRequest(username, password string) *http.Request {
|
|
form := url.Values{}
|
|
form.Set("username", username)
|
|
form.Set("password", password)
|
|
|
|
request := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodPost,
|
|
"/login",
|
|
strings.NewReader(form.Encode()),
|
|
)
|
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
return request
|
|
}
|
|
|
|
func TestHandleLoginPOSTAuthenticatesValidCredentials(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCtx := setupTestHandlers(t)
|
|
|
|
// Create user first
|
|
_, createErr := testCtx.authSvc.CreateUser(
|
|
context.Background(),
|
|
"testuser",
|
|
"testpass123",
|
|
)
|
|
require.NoError(t, createErr)
|
|
|
|
request := createLoginFormRequest("testuser", "testpass123")
|
|
recorder := httptest.NewRecorder()
|
|
|
|
handler := testCtx.handlers.HandleLoginPOST()
|
|
handler.ServeHTTP(recorder, request)
|
|
|
|
assert.Equal(t, http.StatusSeeOther, recorder.Code)
|
|
assert.Equal(t, "/", recorder.Header().Get("Location"))
|
|
}
|
|
|
|
func TestHandleLoginPOSTRejectsInvalidCredentials(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCtx := setupTestHandlers(t)
|
|
|
|
// Create user first
|
|
_, createErr := testCtx.authSvc.CreateUser(
|
|
context.Background(),
|
|
"testuser",
|
|
"testpass123",
|
|
)
|
|
require.NoError(t, createErr)
|
|
|
|
request := createLoginFormRequest("testuser", "wrongpassword")
|
|
recorder := httptest.NewRecorder()
|
|
|
|
handler := testCtx.handlers.HandleLoginPOST()
|
|
handler.ServeHTTP(recorder, request)
|
|
|
|
assert.Equal(t, http.StatusOK, recorder.Code)
|
|
assert.Contains(t, recorder.Body.String(), "Invalid")
|
|
}
|
|
|
|
func TestHandleDashboard(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("renders dashboard with app list", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCtx := setupTestHandlers(t)
|
|
|
|
request := httptest.NewRequestWithContext(
|
|
t.Context(), http.MethodGet, "/", nil,
|
|
)
|
|
recorder := httptest.NewRecorder()
|
|
|
|
handler := testCtx.handlers.HandleDashboard()
|
|
handler.ServeHTTP(recorder, request)
|
|
|
|
assert.Equal(t, http.StatusOK, recorder.Code)
|
|
assert.Contains(t, recorder.Body.String(), "Applications")
|
|
})
|
|
|
|
t.Run("renders dashboard with apps without crashing on CSRFField", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCtx := setupTestHandlers(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,
|
|
)
|
|
recorder := httptest.NewRecorder()
|
|
|
|
handler := testCtx.handlers.HandleDashboard()
|
|
handler.ServeHTTP(recorder, request)
|
|
|
|
assert.Equal(t, http.StatusOK, recorder.Code,
|
|
"dashboard should not 500 when apps exist (CSRFField must be accessible)")
|
|
assert.Contains(t, recorder.Body.String(), "csrf-test-app")
|
|
})
|
|
}
|
|
|
|
func TestHandleAppNew(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("renders new app form", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCtx := setupTestHandlers(t)
|
|
|
|
request := httptest.NewRequestWithContext(
|
|
t.Context(), http.MethodGet, "/apps/new", nil,
|
|
)
|
|
recorder := httptest.NewRecorder()
|
|
|
|
handler := testCtx.handlers.HandleAppNew()
|
|
handler.ServeHTTP(recorder, request)
|
|
|
|
assert.Equal(t, http.StatusOK, recorder.Code)
|
|
})
|
|
}
|
|
|
|
// addChiURLParams adds chi URL parameters to a request for testing.
|
|
func addChiURLParams(
|
|
request *http.Request,
|
|
params map[string]string,
|
|
) *http.Request {
|
|
routeContext := chi.NewRouteContext()
|
|
|
|
for key, value := range params {
|
|
routeContext.URLParams.Add(key, value)
|
|
}
|
|
|
|
return request.WithContext(
|
|
context.WithValue(request.Context(), chi.RouteCtxKey, routeContext),
|
|
)
|
|
}
|
|
|
|
// createTestApp creates an app using the app service and returns it.
|
|
func createTestApp(
|
|
t *testing.T,
|
|
tc *testContext,
|
|
name string,
|
|
) *models.App {
|
|
t.Helper()
|
|
|
|
createdApp, err := tc.appSvc.CreateApp(
|
|
context.Background(),
|
|
app.CreateAppInput{
|
|
Name: name,
|
|
RepoURL: "git@example.com:user/" + name + ".git",
|
|
Branch: branchMain,
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
return createdApp
|
|
}
|
|
|
|
// TestHandleWebhookRejectsOversizedBody tests that oversized webhook payloads
|
|
// are handled gracefully.
|
|
func TestHandleWebhookRejectsOversizedBody(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCtx := setupTestHandlers(t)
|
|
|
|
// Create an app first
|
|
createdApp, createErr := testCtx.appSvc.CreateApp(
|
|
context.Background(),
|
|
app.CreateAppInput{
|
|
Name: "oversize-test-app",
|
|
RepoURL: "git@example.com:user/repo.git",
|
|
Branch: branchMain,
|
|
},
|
|
)
|
|
require.NoError(t, createErr)
|
|
|
|
// Create a body larger than 1MB - it should be silently truncated
|
|
// and the webhook should still process (or fail gracefully on parse)
|
|
largePayload := strings.Repeat("x", 2*1024*1024) // 2MB
|
|
request := httptest.NewRequestWithContext(
|
|
t.Context(),
|
|
http.MethodPost,
|
|
"/webhook/"+createdApp.WebhookSecret,
|
|
strings.NewReader(largePayload),
|
|
)
|
|
request = addChiURLParams(
|
|
request,
|
|
map[string]string{paramSecret: createdApp.WebhookSecret},
|
|
)
|
|
request.Header.Set("Content-Type", "application/json")
|
|
request.Header.Set("X-Gitea-Event", "push")
|
|
|
|
recorder := httptest.NewRecorder()
|
|
|
|
handler := testCtx.handlers.HandleWebhook()
|
|
handler.ServeHTTP(recorder, request)
|
|
|
|
// Should still return OK (payload is truncated and fails JSON parse,
|
|
// but webhook service handles invalid JSON gracefully)
|
|
assert.Equal(t, http.StatusOK, recorder.Code)
|
|
}
|
|
|
|
// ownedResourceTestConfig configures an IDOR ownership verification test.
|
|
type ownedResourceTestConfig struct {
|
|
appPrefix1 string
|
|
appPrefix2 string
|
|
createFn func(t *testing.T, tc *testContext, app *models.App) int64
|
|
deletePath func(appID string, resourceID int64) string
|
|
chiParams func(appID string, resourceID int64) map[string]string
|
|
handler func(h *handlers.Handlers) http.HandlerFunc
|
|
verifyFn func(t *testing.T, tc *testContext, resourceID int64)
|
|
}
|
|
|
|
func testOwnershipVerification(t *testing.T, cfg ownedResourceTestConfig) {
|
|
t.Helper()
|
|
|
|
testCtx := setupTestHandlers(t)
|
|
|
|
app1 := createTestApp(t, testCtx, cfg.appPrefix1)
|
|
app2 := createTestApp(t, testCtx, cfg.appPrefix2)
|
|
|
|
resourceID := cfg.createFn(t, testCtx, app1)
|
|
|
|
request := httptest.NewRequestWithContext(
|
|
t.Context(),
|
|
http.MethodPost,
|
|
cfg.deletePath(app2.ID, resourceID),
|
|
nil,
|
|
)
|
|
request = addChiURLParams(request, cfg.chiParams(app2.ID, resourceID))
|
|
|
|
recorder := httptest.NewRecorder()
|
|
|
|
handler := cfg.handler(testCtx.handlers)
|
|
handler.ServeHTTP(recorder, request)
|
|
|
|
assert.Equal(t, http.StatusNotFound, recorder.Code)
|
|
cfg.verifyFn(t, testCtx, resourceID)
|
|
}
|
|
|
|
// TestHandleEnvVarSaveBulk tests that HandleEnvVarSave replaces all env vars
|
|
// for an app with the submitted set (monolithic delete-all + insert-all).
|
|
func TestHandleEnvVarSaveBulk(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCtx := setupTestHandlers(t)
|
|
createdApp := createTestApp(t, testCtx, "envvar-bulk-app")
|
|
|
|
// Create some pre-existing env vars
|
|
for _, kv := range [][2]string{{"OLD_KEY", "old_value"}, {"REMOVE_ME", "gone"}} {
|
|
ev := models.NewEnvVar(testCtx.database)
|
|
ev.AppID = createdApp.ID
|
|
ev.Key = kv[0]
|
|
ev.Value = kv[1]
|
|
require.NoError(t, ev.Save(context.Background()))
|
|
}
|
|
|
|
// Submit a new set as a JSON array of key/value objects
|
|
body := `[{"key":"NEW_KEY","value":"new_value"},{"key":"ANOTHER","value":"42"}]`
|
|
|
|
r := chi.NewRouter()
|
|
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
|
|
|
|
request := httptest.NewRequestWithContext(
|
|
t.Context(),
|
|
http.MethodPost,
|
|
"/apps/"+createdApp.ID+"/env",
|
|
strings.NewReader(body),
|
|
)
|
|
request.Header.Set("Content-Type", "application/json")
|
|
|
|
recorder := httptest.NewRecorder()
|
|
r.ServeHTTP(recorder, request)
|
|
|
|
assert.Equal(t, http.StatusOK, recorder.Code)
|
|
|
|
// Verify old env vars are gone and new ones exist
|
|
envVars, err := models.FindEnvVarsByAppID(
|
|
context.Background(), testCtx.database, createdApp.ID,
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Len(t, envVars, 2)
|
|
|
|
keys := make(map[string]string)
|
|
for _, ev := range envVars {
|
|
keys[ev.Key] = ev.Value
|
|
}
|
|
|
|
assert.Equal(t, "new_value", keys["NEW_KEY"])
|
|
assert.Equal(t, "42", keys["ANOTHER"])
|
|
assert.Empty(t, keys["OLD_KEY"], "old env vars should be deleted")
|
|
assert.Empty(t, keys["REMOVE_ME"], "old env vars should be deleted")
|
|
}
|
|
|
|
// TestHandleEnvVarSaveAppNotFound tests that HandleEnvVarSave returns 404
|
|
// for a non-existent app.
|
|
func TestHandleEnvVarSaveAppNotFound(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCtx := setupTestHandlers(t)
|
|
|
|
body := `[{"key":"KEY","value":"value"}]`
|
|
|
|
r := chi.NewRouter()
|
|
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
|
|
|
|
request := httptest.NewRequestWithContext(
|
|
t.Context(),
|
|
http.MethodPost,
|
|
"/apps/nonexistent-id/env",
|
|
strings.NewReader(body),
|
|
)
|
|
request.Header.Set("Content-Type", "application/json")
|
|
|
|
recorder := httptest.NewRecorder()
|
|
r.ServeHTTP(recorder, request)
|
|
|
|
assert.Equal(t, http.StatusNotFound, recorder.Code)
|
|
}
|
|
|
|
// TestHandleEnvVarSaveEmptyKeyRejected verifies that submitting a JSON
|
|
// array containing an entry with an empty key returns 400.
|
|
func TestHandleEnvVarSaveEmptyKeyRejected(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCtx := setupTestHandlers(t)
|
|
createdApp := createTestApp(t, testCtx, "envvar-emptykey-app")
|
|
|
|
body := `[{"key":"VALID_KEY","value":"ok"},{"key":"","value":"bad"}]`
|
|
|
|
r := chi.NewRouter()
|
|
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
|
|
|
|
request := httptest.NewRequestWithContext(
|
|
t.Context(),
|
|
http.MethodPost,
|
|
"/apps/"+createdApp.ID+"/env",
|
|
strings.NewReader(body),
|
|
)
|
|
request.Header.Set("Content-Type", "application/json")
|
|
|
|
recorder := httptest.NewRecorder()
|
|
r.ServeHTTP(recorder, request)
|
|
|
|
assert.Equal(t, http.StatusBadRequest, recorder.Code)
|
|
}
|
|
|
|
// TestHandleEnvVarSaveDuplicateKeyRejected verifies that when the client
|
|
// sends duplicate keys, the server rejects them with 400 Bad Request.
|
|
func TestHandleEnvVarSaveDuplicateKeyRejected(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCtx := setupTestHandlers(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"}]`
|
|
|
|
r := chi.NewRouter()
|
|
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
|
|
|
|
request := httptest.NewRequestWithContext(
|
|
t.Context(),
|
|
http.MethodPost,
|
|
"/apps/"+createdApp.ID+"/env",
|
|
strings.NewReader(body),
|
|
)
|
|
request.Header.Set("Content-Type", "application/json")
|
|
|
|
recorder := httptest.NewRecorder()
|
|
r.ServeHTTP(recorder, request)
|
|
|
|
assert.Equal(t, http.StatusBadRequest, recorder.Code)
|
|
assert.Contains(t, recorder.Body.String(), "duplicate environment variable key: FOO")
|
|
}
|
|
|
|
// TestHandleEnvVarSaveCrossAppIsolation verifies that posting env vars
|
|
// to appA's endpoint does not affect appB's env vars (IDOR prevention).
|
|
func TestHandleEnvVarSaveCrossAppIsolation(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCtx := setupTestHandlers(t)
|
|
appA := createTestApp(t, testCtx, "envvar-iso-appA")
|
|
appB := createTestApp(t, testCtx, "envvar-iso-appB")
|
|
|
|
// Give appB some env vars
|
|
for _, kv := range [][2]string{{"B_KEY1", "b_val1"}, {"B_KEY2", "b_val2"}} {
|
|
ev := models.NewEnvVar(testCtx.database)
|
|
ev.AppID = appB.ID
|
|
ev.Key = kv[0]
|
|
ev.Value = kv[1]
|
|
require.NoError(t, ev.Save(context.Background()))
|
|
}
|
|
|
|
// POST new env vars to appA's endpoint
|
|
body := `[{"key":"A_KEY","value":"a_val"}]`
|
|
|
|
r := chi.NewRouter()
|
|
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
|
|
|
|
request := httptest.NewRequestWithContext(
|
|
t.Context(),
|
|
http.MethodPost,
|
|
"/apps/"+appA.ID+"/env",
|
|
strings.NewReader(body),
|
|
)
|
|
request.Header.Set("Content-Type", "application/json")
|
|
|
|
recorder := httptest.NewRecorder()
|
|
r.ServeHTTP(recorder, request)
|
|
|
|
assert.Equal(t, http.StatusOK, recorder.Code)
|
|
|
|
// Verify appA has exactly what we sent
|
|
appAVars, err := models.FindEnvVarsByAppID(
|
|
context.Background(), testCtx.database, appA.ID,
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Len(t, appAVars, 1)
|
|
assert.Equal(t, "A_KEY", appAVars[0].Key)
|
|
|
|
// Verify appB's env vars are completely untouched
|
|
appBVars, err := models.FindEnvVarsByAppID(
|
|
context.Background(), testCtx.database, appB.ID,
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Len(t, appBVars, 2, "appB env vars must not be affected")
|
|
|
|
bKeys := make(map[string]string)
|
|
for _, ev := range appBVars {
|
|
bKeys[ev.Key] = ev.Value
|
|
}
|
|
|
|
assert.Equal(t, "b_val1", bKeys["B_KEY1"])
|
|
assert.Equal(t, "b_val2", bKeys["B_KEY2"])
|
|
}
|
|
|
|
// TestHandleEnvVarSaveBodySizeLimit verifies that a request body
|
|
// exceeding the 1 MB limit is rejected.
|
|
func TestHandleEnvVarSaveBodySizeLimit(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCtx := setupTestHandlers(t)
|
|
createdApp := createTestApp(t, testCtx, "envvar-sizelimit-app")
|
|
|
|
// Build a JSON body that exceeds 1 MB
|
|
// Each entry is ~30 bytes; 40000 entries ≈ 1.2 MB
|
|
var sb strings.Builder
|
|
|
|
sb.WriteString("[")
|
|
|
|
for i := range 40000 {
|
|
if i > 0 {
|
|
sb.WriteString(",")
|
|
}
|
|
|
|
sb.WriteString(`{"key":"K` + strconv.Itoa(i) + `","value":"val"}`)
|
|
}
|
|
|
|
sb.WriteString("]")
|
|
|
|
r := chi.NewRouter()
|
|
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
|
|
|
|
request := httptest.NewRequestWithContext(
|
|
t.Context(),
|
|
http.MethodPost,
|
|
"/apps/"+createdApp.ID+"/env",
|
|
strings.NewReader(sb.String()),
|
|
)
|
|
request.Header.Set("Content-Type", "application/json")
|
|
|
|
recorder := httptest.NewRecorder()
|
|
r.ServeHTTP(recorder, request)
|
|
|
|
assert.Equal(t, http.StatusBadRequest, recorder.Code,
|
|
"oversized body should be rejected with 400")
|
|
}
|
|
|
|
// TestDeleteLabelOwnershipVerification tests that deleting a label
|
|
// via another app's URL path returns 404 (IDOR prevention).
|
|
func TestDeleteLabelOwnershipVerification(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testOwnershipVerification(t, ownedResourceTestConfig{
|
|
appPrefix1: "label-owner-app",
|
|
appPrefix2: "label-other-app",
|
|
createFn: func(t *testing.T, tc *testContext, ownerApp *models.App) int64 {
|
|
t.Helper()
|
|
|
|
lbl := models.NewLabel(tc.database)
|
|
lbl.AppID = ownerApp.ID
|
|
lbl.Key = "traefik.enable"
|
|
lbl.Value = "true"
|
|
require.NoError(t, lbl.Save(context.Background()))
|
|
|
|
return lbl.ID
|
|
},
|
|
deletePath: func(appID string, resourceID int64) string {
|
|
return "/apps/" + appID + "/labels/" + strconv.FormatInt(resourceID, 10) + "/delete"
|
|
},
|
|
chiParams: func(appID string, resourceID int64) map[string]string {
|
|
return map[string]string{"id": appID, "labelID": strconv.FormatInt(resourceID, 10)}
|
|
},
|
|
handler: func(h *handlers.Handlers) http.HandlerFunc { return h.HandleLabelDelete() },
|
|
verifyFn: func(t *testing.T, tc *testContext, resourceID int64) {
|
|
t.Helper()
|
|
|
|
found, findErr := models.FindLabel(context.Background(), tc.database, resourceID)
|
|
require.NoError(t, findErr)
|
|
assert.NotNil(t, found, "label should still exist after IDOR attempt")
|
|
},
|
|
})
|
|
}
|
|
|
|
// TestDeleteVolumeOwnershipVerification tests that deleting a volume
|
|
// via another app's URL path returns 404 (IDOR prevention).
|
|
func TestDeleteVolumeOwnershipVerification(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCtx := setupTestHandlers(t)
|
|
|
|
app1 := createTestApp(t, testCtx, "volume-owner-app")
|
|
app2 := createTestApp(t, testCtx, "volume-other-app")
|
|
|
|
// Create volume belonging to app1
|
|
volume := models.NewVolume(testCtx.database)
|
|
volume.AppID = app1.ID
|
|
volume.HostPath = "/data/app1"
|
|
volume.ContainerPath = "/app/data"
|
|
volume.ReadOnly = false
|
|
require.NoError(t, volume.Save(context.Background()))
|
|
|
|
// Try to delete app1's volume using app2's URL path
|
|
request := httptest.NewRequestWithContext(
|
|
t.Context(),
|
|
http.MethodPost,
|
|
"/apps/"+app2.ID+"/volumes/"+strconv.FormatInt(volume.ID, 10)+"/delete",
|
|
nil,
|
|
)
|
|
request = addChiURLParams(request, map[string]string{
|
|
"id": app2.ID,
|
|
"volumeID": strconv.FormatInt(volume.ID, 10),
|
|
})
|
|
recorder := httptest.NewRecorder()
|
|
|
|
handler := testCtx.handlers.HandleVolumeDelete()
|
|
handler.ServeHTTP(recorder, request)
|
|
|
|
assert.Equal(t, http.StatusNotFound, recorder.Code)
|
|
|
|
// Verify the volume was NOT deleted
|
|
found, err := models.FindVolume(context.Background(), testCtx.database, volume.ID)
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, found, "volume should still exist after IDOR attempt")
|
|
}
|
|
|
|
// TestDeletePortOwnershipVerification tests that deleting a port
|
|
// via another app's URL path returns 404 (IDOR prevention).
|
|
func TestDeletePortOwnershipVerification(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCtx := setupTestHandlers(t)
|
|
|
|
app1 := createTestApp(t, testCtx, "port-owner-app")
|
|
app2 := createTestApp(t, testCtx, "port-other-app")
|
|
|
|
// Create port belonging to app1
|
|
port := models.NewPort(testCtx.database)
|
|
port.AppID = app1.ID
|
|
port.HostPort = 8080
|
|
port.ContainerPort = 80
|
|
port.Protocol = models.PortProtocolTCP
|
|
require.NoError(t, port.Save(context.Background()))
|
|
|
|
// Try to delete app1's port using app2's URL path
|
|
request := httptest.NewRequestWithContext(
|
|
t.Context(),
|
|
http.MethodPost,
|
|
"/apps/"+app2.ID+"/ports/"+strconv.FormatInt(port.ID, 10)+"/delete",
|
|
nil,
|
|
)
|
|
request = addChiURLParams(request, map[string]string{
|
|
"id": app2.ID,
|
|
"portID": strconv.FormatInt(port.ID, 10),
|
|
})
|
|
recorder := httptest.NewRecorder()
|
|
|
|
handler := testCtx.handlers.HandlePortDelete()
|
|
handler.ServeHTTP(recorder, request)
|
|
|
|
assert.Equal(t, http.StatusNotFound, recorder.Code)
|
|
|
|
// Verify the port was NOT deleted
|
|
found, err := models.FindPort(context.Background(), testCtx.database, port.ID)
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, found, "port should still exist after IDOR attempt")
|
|
}
|
|
|
|
// TestHandleEnvVarSaveEmptyClears verifies that submitting an empty JSON
|
|
// array deletes all existing env vars for the app.
|
|
func TestHandleEnvVarSaveEmptyClears(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCtx := setupTestHandlers(t)
|
|
createdApp := createTestApp(t, testCtx, "envvar-clear-app")
|
|
|
|
// Create a pre-existing env var
|
|
ev := models.NewEnvVar(testCtx.database)
|
|
ev.AppID = createdApp.ID
|
|
ev.Key = "DELETE_ME"
|
|
ev.Value = "gone"
|
|
require.NoError(t, ev.Save(context.Background()))
|
|
|
|
// Submit empty JSON array
|
|
r := chi.NewRouter()
|
|
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
|
|
|
|
request := httptest.NewRequestWithContext(
|
|
t.Context(),
|
|
http.MethodPost,
|
|
"/apps/"+createdApp.ID+"/env",
|
|
strings.NewReader("[]"),
|
|
)
|
|
request.Header.Set("Content-Type", "application/json")
|
|
|
|
recorder := httptest.NewRecorder()
|
|
r.ServeHTTP(recorder, request)
|
|
|
|
assert.Equal(t, http.StatusOK, recorder.Code)
|
|
|
|
// Verify all env vars are gone
|
|
envVars, err := models.FindEnvVarsByAppID(
|
|
context.Background(), testCtx.database, createdApp.ID,
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, envVars, "all env vars should be deleted")
|
|
}
|
|
|
|
// TestHandleVolumeAddValidatesPaths verifies that HandleVolumeAdd validates
|
|
// host and container paths (same as HandleVolumeEdit).
|
|
func TestHandleVolumeAddValidatesPaths(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCtx := setupTestHandlers(t)
|
|
|
|
createdApp := createTestApp(t, testCtx, "volume-validate-app")
|
|
|
|
tests := []struct {
|
|
name string
|
|
hostPath string
|
|
containerPath string
|
|
shouldCreate bool
|
|
}{
|
|
{"relative host path rejected", "relative/path", "/container", false},
|
|
{"relative container path rejected", "/host", "relative/path", false},
|
|
{"unclean host path rejected", "/host/../etc", "/container", false},
|
|
{"valid paths accepted", "/host/data", "/container/data", true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
form := url.Values{}
|
|
form.Set("host_path", tt.hostPath)
|
|
form.Set("container_path", tt.containerPath)
|
|
|
|
request := httptest.NewRequestWithContext(
|
|
t.Context(),
|
|
http.MethodPost,
|
|
"/apps/"+createdApp.ID+"/volumes",
|
|
strings.NewReader(form.Encode()),
|
|
)
|
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
request = addChiURLParams(request, map[string]string{"id": createdApp.ID})
|
|
|
|
recorder := httptest.NewRecorder()
|
|
|
|
handler := testCtx.handlers.HandleVolumeAdd()
|
|
handler.ServeHTTP(recorder, request)
|
|
|
|
assert.Equal(t, http.StatusSeeOther, recorder.Code)
|
|
|
|
// Check if volume was created by listing volumes
|
|
volumes, _ := createdApp.GetVolumes(context.Background())
|
|
found := false
|
|
|
|
for _, v := range volumes {
|
|
if v.HostPath == tt.hostPath && v.ContainerPath == tt.containerPath {
|
|
found = true
|
|
// Clean up for isolation
|
|
_ = v.Delete(context.Background())
|
|
}
|
|
}
|
|
|
|
if tt.shouldCreate {
|
|
assert.True(t, found, "volume should be created for valid paths")
|
|
} else {
|
|
assert.False(t, found, "volume should NOT be created for invalid paths")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSetupRequiredExemptsHealthAndStaticAndAPI verifies that the SetupRequired
|
|
// middleware allows /health, /s/*, and /api/* paths through even when setup is
|
|
// required.
|
|
func TestSetupRequiredExemptsHealthAndStaticAndAPI(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCtx := setupTestHandlers(t)
|
|
|
|
// No user created, so setup IS required
|
|
mw := testCtx.middleware.SetupRequired()
|
|
|
|
okHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("OK"))
|
|
})
|
|
|
|
wrapped := mw(okHandler)
|
|
|
|
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,
|
|
)
|
|
rr := httptest.NewRecorder()
|
|
wrapped.ServeHTTP(rr, req)
|
|
|
|
assert.Equal(t, http.StatusOK, rr.Code,
|
|
"path %s should be exempt from setup redirect", path)
|
|
})
|
|
}
|
|
|
|
// Non-exempt path should redirect to /setup
|
|
t.Run("non-exempt redirects", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
t.Context(), http.MethodGet, "/", nil,
|
|
)
|
|
rr := httptest.NewRecorder()
|
|
wrapped.ServeHTTP(rr, req)
|
|
|
|
assert.Equal(t, http.StatusSeeOther, rr.Code)
|
|
assert.Equal(t, "/setup", rr.Header().Get("Location"))
|
|
})
|
|
}
|
|
|
|
func TestHandleCancelDeployRedirects(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCtx := setupTestHandlers(t)
|
|
|
|
createdApp := createTestApp(t, testCtx, "cancel-deploy-app")
|
|
|
|
request := httptest.NewRequestWithContext(
|
|
t.Context(),
|
|
http.MethodPost,
|
|
"/apps/"+createdApp.ID+"/deployments/cancel",
|
|
nil,
|
|
)
|
|
request = addChiURLParams(request, map[string]string{"id": createdApp.ID})
|
|
recorder := httptest.NewRecorder()
|
|
|
|
handler := testCtx.handlers.HandleCancelDeploy()
|
|
handler.ServeHTTP(recorder, request)
|
|
|
|
assert.Equal(t, http.StatusSeeOther, recorder.Code)
|
|
assert.Equal(t, "/apps/"+createdApp.ID, recorder.Header().Get("Location"))
|
|
}
|
|
|
|
func TestHandleCancelDeployReturns404ForUnknownApp(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCtx := setupTestHandlers(t)
|
|
|
|
request := httptest.NewRequestWithContext(
|
|
t.Context(),
|
|
http.MethodPost,
|
|
"/apps/nonexistent/deployments/cancel",
|
|
nil,
|
|
)
|
|
request = addChiURLParams(request, map[string]string{"id": "nonexistent"})
|
|
recorder := httptest.NewRecorder()
|
|
|
|
handler := testCtx.handlers.HandleCancelDeploy()
|
|
handler.ServeHTTP(recorder, request)
|
|
|
|
assert.Equal(t, http.StatusNotFound, recorder.Code)
|
|
}
|
|
|
|
func TestHandleWebhookReturns404ForUnknownSecret(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCtx := setupTestHandlers(t)
|
|
|
|
webhookURL := "/webhook/unknown-secret"
|
|
payload := `{"ref": "refs/heads/main"}`
|
|
request := httptest.NewRequestWithContext(
|
|
t.Context(),
|
|
http.MethodPost,
|
|
webhookURL,
|
|
strings.NewReader(payload),
|
|
)
|
|
request = addChiURLParams(
|
|
request,
|
|
map[string]string{paramSecret: "unknown-secret"},
|
|
)
|
|
request.Header.Set("Content-Type", "application/json")
|
|
request.Header.Set("X-Gitea-Event", "push")
|
|
|
|
recorder := httptest.NewRecorder()
|
|
|
|
handler := testCtx.handlers.HandleWebhook()
|
|
handler.ServeHTTP(recorder, request)
|
|
|
|
assert.Equal(t, http.StatusNotFound, recorder.Code)
|
|
}
|
|
|
|
func TestHandleWebhookProcessesValidWebhook(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCtx := setupTestHandlers(t)
|
|
|
|
// Create an app first
|
|
createdApp, createErr := testCtx.appSvc.CreateApp(
|
|
context.Background(),
|
|
app.CreateAppInput{
|
|
Name: "webhook-test-app",
|
|
RepoURL: "git@example.com:user/repo.git",
|
|
Branch: branchMain,
|
|
},
|
|
)
|
|
require.NoError(t, createErr)
|
|
|
|
payload := `{"ref": "refs/heads/main", "after": "abc123"}`
|
|
webhookURL := "/webhook/" + createdApp.WebhookSecret
|
|
request := httptest.NewRequestWithContext(
|
|
t.Context(),
|
|
http.MethodPost,
|
|
webhookURL,
|
|
strings.NewReader(payload),
|
|
)
|
|
request = addChiURLParams(
|
|
request,
|
|
map[string]string{paramSecret: createdApp.WebhookSecret},
|
|
)
|
|
request.Header.Set("Content-Type", "application/json")
|
|
request.Header.Set("X-Gitea-Event", "push")
|
|
|
|
recorder := httptest.NewRecorder()
|
|
|
|
handler := testCtx.handlers.HandleWebhook()
|
|
handler.ServeHTTP(recorder, request)
|
|
|
|
assert.Equal(t, http.StatusOK, recorder.Code)
|
|
|
|
// Allow async deployment goroutine to complete before test cleanup.
|
|
// The deployment will fail quickly (docker not connected) but we need
|
|
// to wait for it to finish to avoid temp directory cleanup race.
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|