vikunja/pkg/webtests/huma_task_attachment_test.go

259 lines
10 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"
"strconv"
"testing"
"code.vikunja.io/api/pkg/config"
"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"
)
// multipartFilesBody builds a multipart/form-data body with one or more files
// under the "files" field, matching the v2 upload handler's form schema.
func multipartFilesBody(t *testing.T, files map[string][]byte) (*bytes.Buffer, string) {
t.Helper()
buf := &bytes.Buffer{}
w := multipart.NewWriter(buf)
for filename, content := range files {
fw, err := w.CreateFormFile("files", filename)
require.NoError(t, err)
_, err = fw.Write(content)
require.NoError(t, err)
}
require.NoError(t, w.Close())
return buf, w.FormDataContentType()
}
func uploadAttachmentRequest(t *testing.T, e *echo.Echo, taskID string, body *bytes.Buffer, contentType, token string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/api/v2/tasks/"+taskID+"/attachments", body)
req.Header.Set("Content-Type", contentType)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
return rec
}
// uploadOneAttachment uploads a single file to task 1 and returns the created
// attachment id, so download/delete tests have a real file in storage to act on
// (setupTestEnv resets the mem storage, so fixture files have no bytes).
func uploadOneAttachment(t *testing.T, e *echo.Echo, token, filename string, content []byte) int64 {
t.Helper()
body, contentType := multipartFilesBody(t, map[string][]byte{filename: content})
rec := uploadAttachmentRequest(t, e, "1", body, contentType, token)
require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
var resp struct {
Body struct {
Success []*models.TaskAttachment `json:"success"`
Errors []struct {
Message string `json:"message"`
} `json:"errors"`
}
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp.Body))
require.Empty(t, resp.Body.Errors, "upload reported per-file errors: %+v", resp.Body.Errors)
require.Len(t, resp.Body.Success, 1)
require.NotZero(t, resp.Body.Success[0].ID)
return resp.Body.Success[0].ID
}
func TestTaskAttachmentsV2(t *testing.T) {
t.Run("Upload single file", func(t *testing.T) {
e, err := setupTestEnv()
require.NoError(t, err)
token := humaTokenFor(t, &testuser1)
body, contentType := multipartFilesBody(t, map[string][]byte{"hello.txt": []byte("hello world")})
rec := uploadAttachmentRequest(t, e, "1", body, contentType, token)
require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
assert.Contains(t, rec.Body.String(), "hello.txt")
assert.Contains(t, rec.Body.String(), `"success"`)
})
t.Run("Upload multiple files", func(t *testing.T) {
e, err := setupTestEnv()
require.NoError(t, err)
token := humaTokenFor(t, &testuser1)
body, contentType := multipartFilesBody(t, map[string][]byte{
"one.txt": []byte("first file"),
"two.txt": []byte("second file"),
})
rec := uploadAttachmentRequest(t, e, "1", body, contentType, token)
require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
var resp struct {
Success []*models.TaskAttachment `json:"success"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
assert.Len(t, resp.Success, 2)
})
t.Run("List", func(t *testing.T) {
e, err := setupTestEnv()
require.NoError(t, err)
token := humaTokenFor(t, &testuser1)
// Upload first so there is at least one attachment with a real file row.
uploadOneAttachment(t, e, token, "listed.txt", []byte("listed content"))
rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/attachments", "", token, "")
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
var resp struct {
Items []*models.TaskAttachment `json:"items"`
Total int64 `json:"total"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
assert.NotEmpty(t, resp.Items)
assert.Positive(t, resp.Total)
})
t.Run("Download returns bytes and content type", func(t *testing.T) {
e, err := setupTestEnv()
require.NoError(t, err)
token := humaTokenFor(t, &testuser1)
content := []byte("downloadable content")
id := uploadOneAttachment(t, e, token, "download.txt", content)
rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/attachments/"+strconv.FormatInt(id, 10), "", token, "")
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
assert.Equal(t, content, rec.Body.Bytes(), "the streamed file bytes must match the original")
assert.NotEmpty(t, rec.Header().Get("Content-Type"))
assert.Contains(t, rec.Header().Get("Content-Disposition"), "download.txt")
// Caching headers mirror v1: a concrete length and a cacheable directive.
assert.Equal(t, strconv.Itoa(len(content)), rec.Header().Get("Content-Length"))
assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control"))
assert.NotEmpty(t, rec.Header().Get("Last-Modified"))
})
t.Run("Delete", func(t *testing.T) {
e, err := setupTestEnv()
require.NoError(t, err)
token := humaTokenFor(t, &testuser1)
id := uploadOneAttachment(t, e, token, "todelete.txt", []byte("bye"))
rec := humaRequest(t, e, http.MethodDelete, "/api/v2/tasks/1/attachments/"+strconv.FormatInt(id, 10), "", token, "")
require.Equal(t, http.StatusNoContent, rec.Code, "body: %s", rec.Body.String())
// The download must now 404.
rec = humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/attachments/"+strconv.FormatInt(id, 10), "", token, "")
assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String())
})
t.Run("Upload forbidden on inaccessible task", func(t *testing.T) {
// Task 34 is owned by user 13 and inaccessible to testuser1 (see the v1 IDOR test).
e, err := setupTestEnv()
require.NoError(t, err)
token := humaTokenFor(t, &testuser1)
body, contentType := multipartFilesBody(t, map[string][]byte{"nope.txt": []byte("nope")})
rec := uploadAttachmentRequest(t, e, "34", body, contentType, token)
assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
})
t.Run("List forbidden on inaccessible task", func(t *testing.T) {
e, err := setupTestEnv()
require.NoError(t, err)
token := humaTokenFor(t, &testuser1)
rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/34/attachments", "", token, "")
assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
})
t.Run("Download nonexistent attachment", func(t *testing.T) {
e, err := setupTestEnv()
require.NoError(t, err)
token := humaTokenFor(t, &testuser1)
rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/attachments/99999", "", token, "")
assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String())
})
t.Run("Cannot download attachment that does not belong to the task in the path", func(t *testing.T) {
// Mirrors the v1 IDOR test: attachment 4 belongs to task 34, not task 1.
// Requesting it under task 1 (accessible) must 404, not leak the file.
e, err := setupTestEnv()
require.NoError(t, err)
token := humaTokenFor(t, &testuser1)
rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/attachments/4", "", token, "")
assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String())
})
t.Run("Unauthenticated upload is rejected", func(t *testing.T) {
e, err := setupTestEnv()
require.NoError(t, err)
body, contentType := multipartFilesBody(t, map[string][]byte{"x.txt": []byte("x")})
rec := uploadAttachmentRequest(t, e, "1", body, contentType, "")
assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String())
})
}
// TestTaskAttachmentsV2_PreviewSize covers the preview_size query param: a non-image
// attachment ignores it and returns the original bytes (the v1 behaviour).
func TestTaskAttachmentsV2_PreviewSize(t *testing.T) {
e, err := setupTestEnv()
require.NoError(t, err)
token := humaTokenFor(t, &testuser1)
content := []byte("not an image, just text")
id := uploadOneAttachment(t, e, token, "notimage.txt", content)
rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/attachments/"+strconv.FormatInt(id, 10)+"?preview_size=md", "", token, "")
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
assert.Equal(t, content, rec.Body.Bytes(), "preview_size on a non-image must return the original file")
}
// TestTaskAttachmentsV2_Disabled proves the resource is absent when the
// service.enabletaskattachments config flag is off.
func TestTaskAttachmentsV2_Disabled(t *testing.T) {
_, err := setupTestEnv()
require.NoError(t, err)
oldValue := config.ServiceEnableTaskAttachments.GetBool()
config.ServiceEnableTaskAttachments.Set(false)
defer config.ServiceEnableTaskAttachments.Set(oldValue)
// Rebuild the router so RegisterAll re-evaluates the (now disabled) flag.
e := routes.NewEcho()
routes.RegisterRoutes(e)
token := humaTokenFor(t, &testuser1)
rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/attachments", "", token, "")
assert.Equal(t, http.StatusNotFound, rec.Code,
"attachment routes must not be registered when the flag is off; body: %s", rec.Body.String())
}