Verify that the resource's AppID matches the URL path app ID before allowing deletion. Without this check, any authenticated user could delete resources belonging to any app by providing the target resource's ID in the URL regardless of the app ID in the path (IDOR vulnerability). Closes #19
725 lines
19 KiB
Go
725 lines
19 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"
|
|
|
|
"git.eeqj.de/sneak/upaas/internal/models"
|
|
|
|
"git.eeqj.de/sneak/upaas/internal/config"
|
|
"git.eeqj.de/sneak/upaas/internal/database"
|
|
"git.eeqj.de/sneak/upaas/internal/docker"
|
|
"git.eeqj.de/sneak/upaas/internal/globals"
|
|
"git.eeqj.de/sneak/upaas/internal/handlers"
|
|
"git.eeqj.de/sneak/upaas/internal/healthcheck"
|
|
"git.eeqj.de/sneak/upaas/internal/logger"
|
|
"git.eeqj.de/sneak/upaas/internal/service/app"
|
|
"git.eeqj.de/sneak/upaas/internal/service/auth"
|
|
"git.eeqj.de/sneak/upaas/internal/service/deploy"
|
|
"git.eeqj.de/sneak/upaas/internal/service/notify"
|
|
"git.eeqj.de/sneak/upaas/internal/service/webhook"
|
|
)
|
|
|
|
type testContext struct {
|
|
handlers *handlers.Handlers
|
|
database *database.Database
|
|
authSvc *auth.Service
|
|
appSvc *app.Service
|
|
}
|
|
|
|
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)
|
|
|
|
return &testContext{
|
|
handlers: handlersInstance,
|
|
database: dbInstance,
|
|
authSvc: authSvc,
|
|
appSvc: appSvc,
|
|
}
|
|
}
|
|
|
|
func TestHandleHealthCheck(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("returns health check response", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCtx := setupTestHandlers(t)
|
|
|
|
request := httptest.NewRequest(
|
|
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")
|
|
})
|
|
}
|
|
|
|
func TestHandleSetupGET(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("renders setup page", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCtx := setupTestHandlers(t)
|
|
|
|
request := httptest.NewRequest(http.MethodGet, "/setup", nil)
|
|
recorder := httptest.NewRecorder()
|
|
|
|
handler := testCtx.handlers.HandleSetupGET()
|
|
handler.ServeHTTP(recorder, request)
|
|
|
|
assert.Equal(t, http.StatusOK, recorder.Code)
|
|
assert.Contains(t, recorder.Body.String(), "setup")
|
|
})
|
|
}
|
|
|
|
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.NewRequest(
|
|
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)
|
|
|
|
request := httptest.NewRequest(http.MethodGet, "/login", nil)
|
|
recorder := httptest.NewRecorder()
|
|
|
|
handler := testCtx.handlers.HandleLoginGET()
|
|
handler.ServeHTTP(recorder, request)
|
|
|
|
assert.Equal(t, http.StatusOK, recorder.Code)
|
|
assert.Contains(t, recorder.Body.String(), "login")
|
|
})
|
|
}
|
|
|
|
func createLoginFormRequest(username, password string) *http.Request {
|
|
form := url.Values{}
|
|
form.Set("username", username)
|
|
form.Set("password", password)
|
|
|
|
request := httptest.NewRequest(
|
|
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.NewRequest(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")
|
|
})
|
|
}
|
|
|
|
func TestHandleAppNew(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("renders new app form", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCtx := setupTestHandlers(t)
|
|
|
|
request := httptest.NewRequest(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: "main",
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
return createdApp
|
|
}
|
|
|
|
// TestDeleteEnvVarOwnershipVerification tests that deleting an env var
|
|
// via another app's URL path returns 404 (IDOR prevention).
|
|
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: "main",
|
|
},
|
|
)
|
|
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.NewRequest(
|
|
http.MethodPost,
|
|
"/webhook/"+createdApp.WebhookSecret,
|
|
strings.NewReader(largePayload),
|
|
)
|
|
request = addChiURLParams(
|
|
request,
|
|
map[string]string{"secret": 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)
|
|
}
|
|
|
|
// TestDeleteEnvVarOwnershipVerification tests that deleting an env var
|
|
// via another app's URL path returns 404 (IDOR prevention).
|
|
func TestDeleteEnvVarOwnershipVerification(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCtx := setupTestHandlers(t)
|
|
|
|
app1 := createTestApp(t, testCtx, "envvar-owner-app")
|
|
app2 := createTestApp(t, testCtx, "envvar-other-app")
|
|
|
|
// Create env var belonging to app1
|
|
envVar := models.NewEnvVar(testCtx.database)
|
|
envVar.AppID = app1.ID
|
|
envVar.Key = "SECRET"
|
|
envVar.Value = "hunter2"
|
|
require.NoError(t, envVar.Save(context.Background()))
|
|
|
|
// Try to delete app1's env var using app2's URL path
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/apps/"+app2.ID+"/env/"+strconv.FormatInt(envVar.ID, 10)+"/delete",
|
|
nil,
|
|
)
|
|
request = addChiURLParams(request, map[string]string{
|
|
"id": app2.ID,
|
|
"envID": strconv.FormatInt(envVar.ID, 10),
|
|
})
|
|
recorder := httptest.NewRecorder()
|
|
|
|
handler := testCtx.handlers.HandleEnvVarDelete()
|
|
handler.ServeHTTP(recorder, request)
|
|
|
|
// Should return 404 because the env var doesn't belong to app2
|
|
assert.Equal(t, http.StatusNotFound, recorder.Code)
|
|
|
|
// Verify the env var was NOT deleted
|
|
found, err := models.FindEnvVar(context.Background(), testCtx.database, envVar.ID)
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, found, "env var should still exist after IDOR attempt")
|
|
}
|
|
|
|
// TestDeleteLabelOwnershipVerification tests that deleting a label
|
|
// via another app's URL path returns 404 (IDOR prevention).
|
|
func TestDeleteLabelOwnershipVerification(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCtx := setupTestHandlers(t)
|
|
|
|
app1 := createTestApp(t, testCtx, "label-owner-app")
|
|
app2 := createTestApp(t, testCtx, "label-other-app")
|
|
|
|
// Create label belonging to app1
|
|
label := models.NewLabel(testCtx.database)
|
|
label.AppID = app1.ID
|
|
label.Key = "traefik.enable"
|
|
label.Value = "true"
|
|
require.NoError(t, label.Save(context.Background()))
|
|
|
|
// Try to delete app1's label using app2's URL path
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/apps/"+app2.ID+"/labels/"+strconv.FormatInt(label.ID, 10)+"/delete",
|
|
nil,
|
|
)
|
|
request = addChiURLParams(request, map[string]string{
|
|
"id": app2.ID,
|
|
"labelID": strconv.FormatInt(label.ID, 10),
|
|
})
|
|
recorder := httptest.NewRecorder()
|
|
|
|
handler := testCtx.handlers.HandleLabelDelete()
|
|
handler.ServeHTTP(recorder, request)
|
|
|
|
assert.Equal(t, http.StatusNotFound, recorder.Code)
|
|
|
|
// Verify the label was NOT deleted
|
|
found, err := models.FindLabel(context.Background(), testCtx.database, label.ID)
|
|
require.NoError(t, err)
|
|
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.NewRequest(
|
|
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.NewRequest(
|
|
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")
|
|
}
|
|
|
|
func TestHandleWebhookReturns404ForUnknownSecret(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCtx := setupTestHandlers(t)
|
|
|
|
webhookURL := "/webhook/unknown-secret"
|
|
payload := `{"ref": "refs/heads/main"}`
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
webhookURL,
|
|
strings.NewReader(payload),
|
|
)
|
|
request = addChiURLParams(request, map[string]string{"secret": "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: "main",
|
|
},
|
|
)
|
|
require.NoError(t, createErr)
|
|
|
|
payload := `{"ref": "refs/heads/main", "after": "abc123"}`
|
|
webhookURL := "/webhook/" + createdApp.WebhookSecret
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
webhookURL,
|
|
strings.NewReader(payload),
|
|
)
|
|
request = addChiURLParams(
|
|
request,
|
|
map[string]string{"secret": 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)
|
|
}
|