Files
vikunja/pkg/webtests/huma_background_upload_test.go
kolaente 3af5eb8208 feat(api/v2): add project background upload on /api/v2
Port PUT /projects/{project}/backgrounds/upload to the Huma-backed v2 API. The
multipart handler reuses handler.ValidateAndSaveBackgroundUpload (shared with
v1), checks project write access explicitly, and is gated on the upload provider
config flag. Adds webtests covering the happy path, auth/permission failures,
non-image rejection, the disabled-provider case and the multipart spec shape.
2026-06-12 08:47:08 +00:00

152 lines
6.2 KiB
Go

// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package webtests
import (
"bytes"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/routes"
"github.com/labstack/echo/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// multipartFileBody builds a multipart body with a single file part under the
// given field name. CreateFormFile sets the part Content-Type to
// application/octet-stream, mirroring how many programmatic clients upload.
func multipartFileBody(t *testing.T, fieldName, filename string, content []byte) (*bytes.Buffer, string) {
t.Helper()
buf := &bytes.Buffer{}
w := multipart.NewWriter(buf)
fw, err := w.CreateFormFile(fieldName, filename)
require.NoError(t, err)
_, err = fw.Write(content)
require.NoError(t, err)
require.NoError(t, w.Close())
return buf, w.FormDataContentType()
}
func uploadBackgroundRequest(t *testing.T, e *echo.Echo, project, token string, body *bytes.Buffer, contentType string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPut, "/api/v2/projects/"+project+"/backgrounds/upload", body)
req.Header.Set("Content-Type", contentType)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
return rec
}
func TestHumaProjectBackgroundUpload(t *testing.T) {
e, err := setupTestEnv()
require.NoError(t, err)
t.Run("Owner uploads a background", func(t *testing.T) {
// testuser1 owns project 1, which starts without a background.
body, contentType := multipartFileBody(t, "background", "bg.png", pngBytes(t))
rec := uploadBackgroundRequest(t, e, "1", humaTokenFor(t, &testuser1), body, contentType)
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
s := db.NewSession()
defer s.Close()
project := models.Project{ID: 1}
has, err := s.Get(&project)
require.NoError(t, err)
require.True(t, has)
assert.NotZero(t, project.BackgroundFileID, "the upload must set a background file id")
assert.NotEmpty(t, project.BackgroundBlurHash, "the upload must compute a blur hash")
})
t.Run("Non-image rejected with 400", func(t *testing.T) {
body, contentType := multipartFileBody(t, "background", "not-an-image.txt", []byte("this is plain text, not an image"))
rec := uploadBackgroundRequest(t, e, "1", humaTokenFor(t, &testuser1), body, contentType)
require.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String())
})
t.Run("Read-only user is forbidden", func(t *testing.T) {
// testuser15 has read-only access to project 35.
body, contentType := multipartFileBody(t, "background", "bg.png", pngBytes(t))
rec := uploadBackgroundRequest(t, e, "35", humaTokenFor(t, &testuser15), body, contentType)
assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
})
t.Run("No access at all is forbidden", func(t *testing.T) {
// testuser1 has no access to project 35.
body, contentType := multipartFileBody(t, "background", "bg.png", pngBytes(t))
rec := uploadBackgroundRequest(t, e, "35", humaTokenFor(t, &testuser1), body, contentType)
assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
})
t.Run("Unauthenticated", func(t *testing.T) {
body, contentType := multipartFileBody(t, "background", "bg.png", pngBytes(t))
rec := uploadBackgroundRequest(t, e, "1", "", body, contentType)
require.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String())
})
t.Run("Renders as multipart in the OpenAPI spec", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v2/openapi.json", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var spec map[string]any
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &spec))
paths, _ := spec["paths"].(map[string]any)
op, _ := paths["/projects/{project}/backgrounds/upload"].(map[string]any)
put, ok := op["put"].(map[string]any)
require.True(t, ok, "PUT /projects/{project}/backgrounds/upload must be in the spec")
content, _ := put["requestBody"].(map[string]any)
contentMap, _ := content["content"].(map[string]any)
mp, ok := contentMap["multipart/form-data"].(map[string]any)
require.True(t, ok, "background upload must be modeled as multipart/form-data")
schema, _ := mp["schema"].(map[string]any)
props, _ := schema["properties"].(map[string]any)
bgProp, ok := props["background"].(map[string]any)
require.True(t, ok, "the background field must appear in the multipart schema")
assert.Equal(t, "binary", bgProp["format"], "background field must be a binary file in the spec")
})
}
// TestHumaProjectBackgroundUploadDisabledByConfig verifies the upload route is
// absent (404) when the upload provider is disabled, even though backgrounds
// themselves are enabled.
func TestHumaProjectBackgroundUploadDisabledByConfig(t *testing.T) {
_, err := setupTestEnv()
require.NoError(t, err)
config.BackgroundsUploadEnabled.Set(false)
defer config.BackgroundsUploadEnabled.Set(true)
e := routes.NewEcho()
routes.RegisterRoutes(e)
body, contentType := multipartFileBody(t, "background", "bg.png", pngBytes(t))
rec := uploadBackgroundRequest(t, e, "1", humaTokenFor(t, &testuser1), body, contentType)
assert.Equal(t, http.StatusNotFound, rec.Code, "route must be absent when background upload is disabled; body: %s", rec.Body.String())
}