Merge pull request 'fix: disable API v1 write methods (closes #112)' (#115) from fix/disable-api-write-methods into main
All checks were successful
Check / check (push) Successful in 11m20s

Reviewed-on: #115
This commit is contained in:
Jeffrey Paul 2026-02-20 14:35:12 +01:00
commit ab526fc93d
4 changed files with 24 additions and 268 deletions

View File

@ -1,7 +1,6 @@
package handlers package handlers
import ( import (
"context"
"encoding/json" "encoding/json"
"net/http" "net/http"
"strconv" "strconv"
@ -9,7 +8,6 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"git.eeqj.de/sneak/upaas/internal/models" "git.eeqj.de/sneak/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/service/app"
) )
// apiAppResponse is the JSON representation of an app. // apiAppResponse is the JSON representation of an app.
@ -176,121 +174,6 @@ func (h *Handlers) HandleAPIGetApp() http.HandlerFunc {
} }
} }
// apiCreateRequest is the JSON body for creating an app via the API.
type apiCreateRequest struct {
Name string `json:"name"`
RepoURL string `json:"repoUrl"`
Branch string `json:"branch"`
DockerfilePath string `json:"dockerfilePath"`
DockerNetwork string `json:"dockerNetwork"`
NtfyTopic string `json:"ntfyTopic"`
SlackWebhook string `json:"slackWebhook"`
}
// validateCreateRequest validates the fields of an API create app request.
// Returns an error message string or empty string if valid.
func validateCreateRequest(req *apiCreateRequest) string {
if req.Name == "" || req.RepoURL == "" {
return "name and repo_url are required"
}
nameErr := validateAppName(req.Name)
if nameErr != nil {
return "invalid app name: " + nameErr.Error()
}
repoURLErr := validateRepoURL(req.RepoURL)
if repoURLErr != nil {
return "invalid repository URL: " + repoURLErr.Error()
}
return ""
}
// HandleAPICreateApp returns a handler that creates a new app.
func (h *Handlers) HandleAPICreateApp() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
var req apiCreateRequest
decodeErr := json.NewDecoder(request.Body).Decode(&req)
if decodeErr != nil {
h.respondJSON(writer, request,
map[string]string{"error": "invalid JSON body"},
http.StatusBadRequest)
return
}
if errMsg := validateCreateRequest(&req); errMsg != "" {
h.respondJSON(writer, request,
map[string]string{"error": errMsg},
http.StatusBadRequest)
return
}
createdApp, createErr := h.appService.CreateApp(request.Context(), app.CreateAppInput{
Name: req.Name,
RepoURL: req.RepoURL,
Branch: req.Branch,
DockerfilePath: req.DockerfilePath,
DockerNetwork: req.DockerNetwork,
NtfyTopic: req.NtfyTopic,
SlackWebhook: req.SlackWebhook,
})
if createErr != nil {
h.log.Error("api: failed to create app", "error", createErr)
h.respondJSON(writer, request,
map[string]string{"error": "failed to create app"},
http.StatusInternalServerError)
return
}
h.respondJSON(writer, request, appToAPI(createdApp), http.StatusCreated)
}
}
// HandleAPIDeleteApp returns a handler that deletes an app.
func (h *Handlers) HandleAPIDeleteApp() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
application, err := h.appService.GetApp(request.Context(), appID)
if err != nil {
h.respondJSON(writer, request,
map[string]string{"error": "internal server error"},
http.StatusInternalServerError)
return
}
if application == nil {
h.respondJSON(writer, request,
map[string]string{"error": "app not found"},
http.StatusNotFound)
return
}
// Stop and remove the Docker container before deleting the DB record
h.cleanupContainer(request.Context(), appID, application.Name)
deleteErr := h.appService.DeleteApp(request.Context(), application)
if deleteErr != nil {
h.log.Error("api: failed to delete app", "error", deleteErr)
h.respondJSON(writer, request,
map[string]string{"error": "failed to delete app"},
http.StatusInternalServerError)
return
}
h.respondJSON(writer, request,
map[string]string{"status": "deleted"}, http.StatusOK)
}
}
// deploymentsPageLimit is the default number of deployments per page. // deploymentsPageLimit is the default number of deployments per page.
const deploymentsPageLimit = 20 const deploymentsPageLimit = 20
@ -337,39 +220,6 @@ func (h *Handlers) HandleAPIListDeployments() http.HandlerFunc {
} }
} }
// HandleAPITriggerDeploy returns a handler that triggers a deployment for an app.
func (h *Handlers) HandleAPITriggerDeploy() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
application, err := h.appService.GetApp(request.Context(), appID)
if err != nil || application == nil {
h.respondJSON(writer, request,
map[string]string{"error": "app not found"},
http.StatusNotFound)
return
}
// Use a detached context so the deployment continues even if the
// HTTP client disconnects.
deployCtx := context.WithoutCancel(request.Context())
deployErr := h.deploy.Deploy(deployCtx, application, nil, true)
if deployErr != nil {
h.log.Error("api: failed to trigger deploy", "error", deployErr)
h.respondJSON(writer, request,
map[string]string{"error": deployErr.Error()},
http.StatusConflict)
return
}
h.respondJSON(writer, request,
map[string]string{"status": "deploying"}, http.StatusAccepted)
}
}
// HandleAPIWhoAmI returns a handler that shows the current authenticated user. // HandleAPIWhoAmI returns a handler that shows the current authenticated user.
func (h *Handlers) HandleAPIWhoAmI() http.HandlerFunc { func (h *Handlers) HandleAPIWhoAmI() http.HandlerFunc {
type whoAmIResponse struct { type whoAmIResponse struct {

View File

@ -10,6 +10,8 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"git.eeqj.de/sneak/upaas/internal/service/app"
) )
// apiRouter builds a chi router with the API routes using session auth middleware. // apiRouter builds a chi router with the API routes using session auth middleware.
@ -23,10 +25,7 @@ func apiRouter(tc *testContext) http.Handler {
apiR.Use(tc.middleware.APISessionAuth()) apiR.Use(tc.middleware.APISessionAuth())
apiR.Get("/whoami", tc.handlers.HandleAPIWhoAmI()) apiR.Get("/whoami", tc.handlers.HandleAPIWhoAmI())
apiR.Get("/apps", tc.handlers.HandleAPIListApps()) apiR.Get("/apps", tc.handlers.HandleAPIListApps())
apiR.Post("/apps", tc.handlers.HandleAPICreateApp())
apiR.Get("/apps/{id}", tc.handlers.HandleAPIGetApp()) apiR.Get("/apps/{id}", tc.handlers.HandleAPIGetApp())
apiR.Delete("/apps/{id}", tc.handlers.HandleAPIDeleteApp())
apiR.Post("/apps/{id}/deploy", tc.handlers.HandleAPITriggerDeploy())
apiR.Get("/apps/{id}/deployments", tc.handlers.HandleAPIListDeployments()) apiR.Get("/apps/{id}/deployments", tc.handlers.HandleAPIListDeployments())
}) })
}) })
@ -62,23 +61,16 @@ func setupAPITest(t *testing.T) (*testContext, []*http.Cookie) {
return tc, cookies return tc, cookies
} }
// apiRequest makes an authenticated API request using session cookies. // apiGet makes an authenticated GET request using session cookies.
func apiRequest( func apiGet(
t *testing.T, t *testing.T,
tc *testContext, tc *testContext,
cookies []*http.Cookie, cookies []*http.Cookie,
method, path string, path string,
body string,
) *httptest.ResponseRecorder { ) *httptest.ResponseRecorder {
t.Helper() t.Helper()
var req *http.Request req := httptest.NewRequest(http.MethodGet, path, nil)
if body != "" {
req = httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, path, nil)
}
for _, c := range cookies { for _, c := range cookies {
req.AddCookie(c) req.AddCookie(c)
@ -175,7 +167,7 @@ func TestAPIWhoAmI(t *testing.T) {
tc, cookies := setupAPITest(t) tc, cookies := setupAPITest(t)
rr := apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/whoami", "") rr := apiGet(t, tc, cookies, "/api/v1/whoami")
assert.Equal(t, http.StatusOK, rr.Code) assert.Equal(t, http.StatusOK, rr.Code)
var resp map[string]any var resp map[string]any
@ -188,7 +180,7 @@ func TestAPIListAppsEmpty(t *testing.T) {
tc, cookies := setupAPITest(t) tc, cookies := setupAPITest(t)
rr := apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps", "") rr := apiGet(t, tc, cookies, "/api/v1/apps")
assert.Equal(t, http.StatusOK, rr.Code) assert.Equal(t, http.StatusOK, rr.Code)
var apps []any var apps []any
@ -196,52 +188,23 @@ func TestAPIListAppsEmpty(t *testing.T) {
assert.Empty(t, apps) assert.Empty(t, apps)
} }
func TestAPICreateApp(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
body := `{"name":"test-app","repoUrl":"https://github.com/example/repo"}`
rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
assert.Equal(t, http.StatusCreated, rr.Code)
var app map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &app))
assert.Equal(t, "test-app", app["name"])
assert.Equal(t, "pending", app["status"])
}
func TestAPICreateAppValidation(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
body := `{"name":"","repoUrl":""}`
rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
assert.Equal(t, http.StatusBadRequest, rr.Code)
}
func TestAPIGetApp(t *testing.T) { func TestAPIGetApp(t *testing.T) {
t.Parallel() t.Parallel()
tc, cookies := setupAPITest(t) tc, cookies := setupAPITest(t)
body := `{"name":"my-app","repoUrl":"https://github.com/example/repo"}` created, err := tc.appSvc.CreateApp(t.Context(), app.CreateAppInput{
rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body) Name: "my-app",
require.Equal(t, http.StatusCreated, rr.Code) RepoURL: "https://github.com/example/repo",
})
require.NoError(t, err)
var created map[string]any rr := apiGet(t, tc, cookies, "/api/v1/apps/"+created.ID)
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created))
appID, ok := created["id"].(string)
require.True(t, ok)
rr = apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps/"+appID, "")
assert.Equal(t, http.StatusOK, rr.Code) assert.Equal(t, http.StatusOK, rr.Code)
var app map[string]any var resp map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &app)) require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
assert.Equal(t, "my-app", app["name"]) assert.Equal(t, "my-app", resp["name"])
} }
func TestAPIGetAppNotFound(t *testing.T) { func TestAPIGetAppNotFound(t *testing.T) {
@ -249,29 +212,7 @@ func TestAPIGetAppNotFound(t *testing.T) {
tc, cookies := setupAPITest(t) tc, cookies := setupAPITest(t)
rr := apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps/nonexistent", "") rr := apiGet(t, tc, cookies, "/api/v1/apps/nonexistent")
assert.Equal(t, http.StatusNotFound, rr.Code)
}
func TestAPIDeleteApp(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
body := `{"name":"delete-me","repoUrl":"https://github.com/example/repo"}`
rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
require.Equal(t, http.StatusCreated, rr.Code)
var created map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created))
appID, ok := created["id"].(string)
require.True(t, ok)
rr = apiRequest(t, tc, cookies, http.MethodDelete, "/api/v1/apps/"+appID, "")
assert.Equal(t, http.StatusOK, rr.Code)
rr = apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps/"+appID, "")
assert.Equal(t, http.StatusNotFound, rr.Code) assert.Equal(t, http.StatusNotFound, rr.Code)
} }
@ -280,17 +221,13 @@ func TestAPIListDeployments(t *testing.T) {
tc, cookies := setupAPITest(t) tc, cookies := setupAPITest(t)
body := `{"name":"deploy-app","repoUrl":"https://github.com/example/repo"}` created, err := tc.appSvc.CreateApp(t.Context(), app.CreateAppInput{
rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body) Name: "deploy-app",
require.Equal(t, http.StatusCreated, rr.Code) RepoURL: "https://github.com/example/repo",
})
require.NoError(t, err)
var created map[string]any rr := apiGet(t, tc, cookies, "/api/v1/apps/"+created.ID+"/deployments")
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created))
appID, ok := created["id"].(string)
require.True(t, ok)
rr = apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps/"+appID+"/deployments", "")
assert.Equal(t, http.StatusOK, rr.Code) assert.Equal(t, http.StatusOK, rr.Code)
var deployments []any var deployments []any

View File

@ -2,7 +2,6 @@ package handlers_test
import ( import (
"context" "context"
"encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
@ -843,33 +842,6 @@ func TestSetupRequiredExemptsHealthAndStaticAndAPI(t *testing.T) {
}) })
} }
// TestAPITriggerDeployUsesDetachedContext verifies that HandleAPITriggerDeploy
// does not pass the request context directly to the deploy operation.
// This is a compile-time/code-level fix verified by the deployment not being
// cancelled when the request context is cancelled.
func TestAPITriggerDeployUsesDetachedContext(t *testing.T) {
t.Parallel()
// This test verifies the fix exists by checking the handler doesn't
// fail when called — the actual context detachment is verified by code review.
// The deploy will fail (no docker) but shouldn't panic.
tc, cookies := setupAPITest(t)
body := `{"name":"detach-ctx-app","repoUrl":"https://github.com/example/repo"}`
rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
require.Equal(t, http.StatusCreated, rr.Code)
var created map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created))
appID, ok := created["id"].(string)
require.True(t, ok)
rr = apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps/"+appID+"/deploy", "")
// Should get conflict (deploy will fail) or accepted, but not panic
assert.Contains(t, []int{http.StatusAccepted, http.StatusConflict}, rr.Code)
}
func TestHandleCancelDeployRedirects(t *testing.T) { func TestHandleCancelDeployRedirects(t *testing.T) {
t.Parallel() t.Parallel()

View File

@ -114,10 +114,7 @@ func (s *Server) SetupRoutes() {
r.Get("/whoami", s.handlers.HandleAPIWhoAmI()) r.Get("/whoami", s.handlers.HandleAPIWhoAmI())
r.Get("/apps", s.handlers.HandleAPIListApps()) r.Get("/apps", s.handlers.HandleAPIListApps())
r.Post("/apps", s.handlers.HandleAPICreateApp())
r.Get("/apps/{id}", s.handlers.HandleAPIGetApp()) r.Get("/apps/{id}", s.handlers.HandleAPIGetApp())
r.Delete("/apps/{id}", s.handlers.HandleAPIDeleteApp())
r.Post("/apps/{id}/deploy", s.handlers.HandleAPITriggerDeploy())
r.Get("/apps/{id}/deployments", s.handlers.HandleAPIListDeployments()) r.Get("/apps/{id}/deployments", s.handlers.HandleAPIListDeployments())
}) })
}) })