Use DataDir/builds instead of /tmp for clone directories so that bind mounts work correctly when upaas itself runs in a Docker container. The /tmp directory inside the upaas container isn't accessible to the Docker daemon on the host, causing bind mount failures. Also fix test setups to pass Config to deploy service and add delay to webhook test to avoid temp directory cleanup race with async deployment goroutine.
495 lines
12 KiB
Go
495 lines
12 KiB
Go
package handlers_test
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"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/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),
|
|
)
|
|
}
|
|
|
|
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)
|
|
}
|