- Add API token model with SHA-256 hashed tokens
- Add migration 006_add_api_tokens.sql
- Add Bearer token auth middleware
- Add API endpoints under /api/v1/:
- GET /whoami
- POST /tokens (create new API token)
- GET /apps (list all apps)
- POST /apps (create app)
- GET /apps/{id} (get app)
- DELETE /apps/{id} (delete app)
- POST /apps/{id}/deploy (trigger deployment)
- GET /apps/{id}/deployments (list deployments)
- Add comprehensive tests for all API endpoints
- All tests pass, zero lint issues
765 lines
20 KiB
Go
765 lines
20 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/middleware"
|
|
"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
|
|
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,
|
|
Database: dbInstance,
|
|
})
|
|
require.NoError(t, mwErr)
|
|
|
|
return &testContext{
|
|
handlers: handlersInstance,
|
|
database: dbInstance,
|
|
authSvc: authSvc,
|
|
appSvc: appSvc,
|
|
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.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
|
|
}
|
|
|
|
// 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: "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)
|
|
}
|
|
|
|
// 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.NewRequest(
|
|
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)
|
|
}
|
|
|
|
// TestDeleteEnvVarOwnershipVerification tests that deleting an env var
|
|
// via another app's URL path returns 404 (IDOR prevention).
|
|
func TestDeleteEnvVarOwnershipVerification(t *testing.T) { //nolint:dupl // intentionally similar IDOR test pattern
|
|
t.Parallel()
|
|
|
|
testOwnershipVerification(t, ownedResourceTestConfig{
|
|
appPrefix1: "envvar-owner-app",
|
|
appPrefix2: "envvar-other-app",
|
|
createFn: func(t *testing.T, tc *testContext, ownerApp *models.App) int64 {
|
|
t.Helper()
|
|
|
|
envVar := models.NewEnvVar(tc.database)
|
|
envVar.AppID = ownerApp.ID
|
|
envVar.Key = "SECRET"
|
|
envVar.Value = "hunter2"
|
|
require.NoError(t, envVar.Save(context.Background()))
|
|
|
|
return envVar.ID
|
|
},
|
|
deletePath: func(appID string, resourceID int64) string {
|
|
return "/apps/" + appID + "/env/" + strconv.FormatInt(resourceID, 10) + "/delete"
|
|
},
|
|
chiParams: func(appID string, resourceID int64) map[string]string {
|
|
return map[string]string{"id": appID, "envID": strconv.FormatInt(resourceID, 10)}
|
|
},
|
|
handler: func(h *handlers.Handlers) http.HandlerFunc { return h.HandleEnvVarDelete() },
|
|
verifyFn: func(t *testing.T, tc *testContext, resourceID int64) {
|
|
t.Helper()
|
|
|
|
found, findErr := models.FindEnvVar(context.Background(), tc.database, resourceID)
|
|
require.NoError(t, findErr)
|
|
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) { //nolint:dupl // intentionally similar IDOR test pattern
|
|
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.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)
|
|
}
|