294 lines
12 KiB
Go
294 lines
12 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 (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"code.vikunja.io/api/pkg/user"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// Bot ownership fixtures (pkg/db/fixtures/users.yml):
|
|
// - user 21 (user_bot_owner_a) owns bot 23 (bot-owner-a-assistant).
|
|
// - user 22 (user_bot_owner_b) owns bot 24 (bot-owner-b-assistant).
|
|
//
|
|
// These two owner/bot pairs give a clean matrix: every read/update/delete of a
|
|
// bot the caller does not own must be refused, and a caller's own bot must be
|
|
// reachable. Constructed locally (not in integrations.go) so this test is
|
|
// self-contained.
|
|
var (
|
|
botOwnerA = user.User{ID: 21, Username: "user_bot_owner_a"}
|
|
botOwnerB = user.User{ID: 22, Username: "user_bot_owner_b"}
|
|
)
|
|
|
|
// TestHumaBotUser ports the v1 bot-user permission matrix to the v2 HTTP
|
|
// surface 1:1 (the v1 coverage lives in pkg/models/bot_users_test.go; there is
|
|
// no v1 webtest). Unlike labels, ownership is verified by loading the user, so
|
|
// every unowned/nonexistent read/update/delete is refused with 403 — there is
|
|
// no 404 branch.
|
|
//
|
|
// One shared env (one fixture load, one signing secret) backs every request;
|
|
// the caller is swapped via h.user. A second env would regenerate the random
|
|
// service secret and invalidate the first env's JWTs.
|
|
func TestHumaBotUser(t *testing.T) {
|
|
h := webHandlerTestV2{
|
|
user: &botOwnerA,
|
|
basePath: "/api/v2/user/bots",
|
|
idParam: "bot",
|
|
t: t,
|
|
}
|
|
// asOwnerB runs fn with the caller temporarily switched to user 22.
|
|
asOwnerB := func(fn func()) {
|
|
h.user = &botOwnerB
|
|
defer func() { h.user = &botOwnerA }()
|
|
fn()
|
|
}
|
|
|
|
t.Run("ReadAll", func(t *testing.T) {
|
|
t.Run("Normal - only own bots", func(t *testing.T) {
|
|
rec, err := h.testReadAllWithUser(nil, nil)
|
|
require.NoError(t, err)
|
|
ids := botIDsFromReadAll(t, rec.Body.Bytes())
|
|
// user 21 owns exactly bot 23; user 22's bot 24 must never leak.
|
|
assert.ElementsMatch(t, []int64{23}, ids,
|
|
"ReadAll must return exactly {23}; body: %s", rec.Body.String())
|
|
assert.NotContains(t, ids, int64(24), "bot #24 (other owner) must be hidden")
|
|
})
|
|
t.Run("Search filters by username", func(t *testing.T) {
|
|
rec, err := h.testReadAllWithUser(map[string][]string{"q": {"nomatch-xyz"}}, nil)
|
|
require.NoError(t, err)
|
|
ids := botIDsFromReadAll(t, rec.Body.Bytes())
|
|
assert.Empty(t, ids, "a non-matching search must return no bots; body: %s", rec.Body.String())
|
|
})
|
|
})
|
|
|
|
t.Run("ReadOne", func(t *testing.T) {
|
|
t.Run("Normal - owner", func(t *testing.T) {
|
|
rec, err := h.testReadOneWithUser(nil, map[string]string{"bot": "23"})
|
|
require.NoError(t, err)
|
|
assert.Contains(t, rec.Body.String(), `"username":"bot-owner-a-assistant"`)
|
|
assert.Contains(t, rec.Body.String(), `"bot_owner_id":21`)
|
|
assert.Contains(t, rec.Body.String(), `"max_permission":`)
|
|
assert.NotEmpty(t, rec.Result().Header.Get("ETag"))
|
|
})
|
|
t.Run("Forbidden - other owner (#24)", func(t *testing.T) {
|
|
_, err := h.testReadOneWithUser(nil, map[string]string{"bot": "24"})
|
|
require.Error(t, err)
|
|
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
|
|
})
|
|
t.Run("Nonexisting refuses with 403", func(t *testing.T) {
|
|
// Ownership is resolved by loading the user; a missing bot is
|
|
// indistinguishable from one owned by someone else, so it is 403,
|
|
// not 404 — existence is never disclosed.
|
|
_, err := h.testReadOneWithUser(nil, map[string]string{"bot": "999999"})
|
|
require.Error(t, err)
|
|
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
|
|
})
|
|
})
|
|
|
|
t.Run("Create", func(t *testing.T) {
|
|
t.Run("Normal", func(t *testing.T) {
|
|
rec, err := h.testCreateWithUser(nil, nil, `{"username":"bot-create-success","name":"Created Bot"}`)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, http.StatusCreated, rec.Code)
|
|
assert.Contains(t, rec.Body.String(), `"username":"bot-create-success"`)
|
|
assert.Contains(t, rec.Body.String(), `"name":"Created Bot"`)
|
|
// The creating user becomes the owner.
|
|
assert.Contains(t, rec.Body.String(), `"bot_owner_id":21`)
|
|
// Bots are created active and carry no email.
|
|
assert.Contains(t, rec.Body.String(), `"status":0`)
|
|
assert.NotContains(t, rec.Body.String(), `"email":`)
|
|
})
|
|
t.Run("Missing bot- prefix", func(t *testing.T) {
|
|
_, err := h.testCreateWithUser(nil, nil, `{"username":"no-prefix-bot"}`)
|
|
require.Error(t, err)
|
|
assert.Equal(t, http.StatusBadRequest, getHTTPErrorCode(err))
|
|
})
|
|
t.Run("Empty username", func(t *testing.T) {
|
|
// minLength:"1" makes Huma reject the body before the model runs (422).
|
|
_, err := h.testCreateWithUser(nil, nil, `{"username":""}`)
|
|
require.Error(t, err)
|
|
assert.Equal(t, http.StatusUnprocessableEntity, getHTTPErrorCode(err))
|
|
})
|
|
t.Run("Username with spaces", func(t *testing.T) {
|
|
// ErrUsernameMustNotContainSpaces maps to 412, matching v1.
|
|
_, err := h.testCreateWithUser(nil, nil, `{"username":"bot- with space"}`)
|
|
require.Error(t, err)
|
|
assert.Equal(t, http.StatusPreconditionFailed, getHTTPErrorCode(err))
|
|
})
|
|
t.Run("Duplicate username", func(t *testing.T) {
|
|
// bot-owner-a-assistant already exists (bot #23).
|
|
_, err := h.testCreateWithUser(nil, nil, `{"username":"bot-owner-a-assistant"}`)
|
|
require.Error(t, err)
|
|
assert.Equal(t, http.StatusBadRequest, getHTTPErrorCode(err))
|
|
})
|
|
})
|
|
|
|
t.Run("Update", func(t *testing.T) {
|
|
t.Run("Normal - rename owned bot", func(t *testing.T) {
|
|
// Renames bot 23 but keeps it active so the Delete cases below can
|
|
// still reach it (disabling poisons GetUserByID with a 412).
|
|
rec, err := h.testUpdateWithUser(nil, map[string]string{"bot": "23"},
|
|
`{"name":"Renamed Bot"}`)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, rec.Body.String(), `"name":"Renamed Bot"`)
|
|
assert.Contains(t, rec.Body.String(), `"status":0`)
|
|
})
|
|
t.Run("Rename owned bot's username", func(t *testing.T) {
|
|
// A new username must keep the bot- prefix.
|
|
rec, err := h.testUpdateWithUser(nil, map[string]string{"bot": "23"},
|
|
`{"username":"bot-owner-a-renamed"}`)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, rec.Body.String(), `"username":"bot-owner-a-renamed"`)
|
|
})
|
|
t.Run("Disable sets status; bot then resolves as disabled (412)", func(t *testing.T) {
|
|
// Disabling is allowed, but once disabled GetUserByID surfaces
|
|
// ErrAccountDisabled, so a follow-up read fails the precondition (412)
|
|
// — same as v1. Use a throwaway bot so bot 23 stays usable.
|
|
rec, err := h.testCreateWithUser(nil, nil, `{"username":"bot-to-disable"}`)
|
|
require.NoError(t, err)
|
|
id := botID(t, rec.Body.Bytes())
|
|
|
|
rec, err = h.testUpdateWithUser(nil, map[string]string{"bot": id}, `{"status":2}`)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, rec.Body.String(), `"status":2`)
|
|
|
|
_, err = h.testReadOneWithUser(nil, map[string]string{"bot": id})
|
|
require.Error(t, err)
|
|
assert.Equal(t, http.StatusPreconditionFailed, getHTTPErrorCode(err))
|
|
})
|
|
t.Run("Forbidden - other owner (#24)", func(t *testing.T) {
|
|
_, err := h.testUpdateWithUser(nil, map[string]string{"bot": "24"}, `{"name":"Nope"}`)
|
|
require.Error(t, err)
|
|
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
|
|
})
|
|
t.Run("Nonexisting refuses with 403", func(t *testing.T) {
|
|
_, err := h.testUpdateWithUser(nil, map[string]string{"bot": "999999"}, `{"name":"Nope"}`)
|
|
require.Error(t, err)
|
|
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
|
|
})
|
|
})
|
|
|
|
t.Run("Delete", func(t *testing.T) {
|
|
t.Run("Forbidden - other owner (#23)", func(t *testing.T) {
|
|
// user 22 does not own bot 23.
|
|
asOwnerB(func() {
|
|
_, err := h.testDeleteWithUser(nil, map[string]string{"bot": "23"})
|
|
require.Error(t, err)
|
|
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
|
|
})
|
|
})
|
|
t.Run("Nonexisting refuses with 403", func(t *testing.T) {
|
|
_, err := h.testDeleteWithUser(nil, map[string]string{"bot": "999999"})
|
|
require.Error(t, err)
|
|
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
|
|
})
|
|
t.Run("Normal", func(t *testing.T) {
|
|
// Runs last so the deleted bot doesn't disturb the assertions above.
|
|
rec, err := h.testDeleteWithUser(nil, map[string]string{"bot": "23"})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, http.StatusNoContent, rec.Code)
|
|
assert.Empty(t, rec.Body.String())
|
|
})
|
|
})
|
|
}
|
|
|
|
// The two tests below cover v2-only behaviour with no v1 counterpart:
|
|
// ETag + conditional requests, and AutoPatch (merge-patch+json).
|
|
|
|
func TestHumaBotUser_ETagReturns304(t *testing.T) {
|
|
e, err := setupTestEnv()
|
|
require.NoError(t, err)
|
|
token := humaTokenFor(t, &botOwnerA)
|
|
|
|
rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/bots/23", "", token, "")
|
|
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
|
etag := rec.Header().Get("ETag")
|
|
require.NotEmpty(t, etag, "GET must return an ETag header")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v2/user/bots/23", strings.NewReader(""))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.Header.Set("If-None-Match", etag)
|
|
rec = httptest.NewRecorder()
|
|
e.ServeHTTP(rec, req)
|
|
require.Equal(t, http.StatusNotModified, rec.Code, "body: %s", rec.Body.String())
|
|
}
|
|
|
|
func TestHumaBotUser_PATCHMergePatch(t *testing.T) {
|
|
e, err := setupTestEnv()
|
|
require.NoError(t, err)
|
|
token := humaTokenFor(t, &botOwnerA)
|
|
|
|
// Create a fresh bot so we don't stomp fixtures.
|
|
rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/bots",
|
|
`{"username":"bot-patch-target","name":"keep me"}`, token, "")
|
|
require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
|
|
id := botID(t, rec.Body.Bytes())
|
|
|
|
// PATCH only the username; AutoPatch must leave name untouched.
|
|
rec = humaRequest(t, e, http.MethodPatch, "/api/v2/user/bots/"+id,
|
|
`{"username":"bot-patched"}`, token, "application/merge-patch+json")
|
|
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
|
|
|
rec = humaRequest(t, e, http.MethodGet, "/api/v2/user/bots/"+id, "", token, "")
|
|
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
|
var after struct {
|
|
Username string `json:"username"`
|
|
Name string `json:"name"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &after))
|
|
assert.Equal(t, "bot-patched", after.Username)
|
|
assert.Equal(t, "keep me", after.Name, "name must survive the PATCH")
|
|
}
|
|
|
|
// botID extracts the id from a single-bot response body as a path string.
|
|
func botID(t *testing.T, body []byte) string {
|
|
t.Helper()
|
|
var resp struct {
|
|
ID int64 `json:"id"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(body, &resp), "body must carry an id: %s", string(body))
|
|
require.NotZero(t, resp.ID, "created bot must have an id: %s", string(body))
|
|
return strconv.FormatInt(resp.ID, 10)
|
|
}
|
|
|
|
// botIDsFromReadAll extracts the bot user IDs from a v2 paginated list body so
|
|
// the owned set can be asserted exactly rather than via substring matching.
|
|
func botIDsFromReadAll(t *testing.T, body []byte) []int64 {
|
|
t.Helper()
|
|
var resp struct {
|
|
Items []struct {
|
|
ID int64 `json:"id"`
|
|
} `json:"items"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(body, &resp), "ReadAll body must be a paginated envelope: %s", string(body))
|
|
ids := make([]int64, 0, len(resp.Items))
|
|
for _, it := range resp.Items {
|
|
ids = append(ids, it.ID)
|
|
}
|
|
return ids
|
|
}
|